Help Desk

Subscription widgets

Show subscriptions and let agents cancel them using standard Python sidebar helpers.

Show a customer's subscriptions in the conversation sidebar with an optional Cancel subscription button. The standard helpers read the connected provider for the conversation's business and look up subscriptions by the customer's email. They display the plan, status, price, renewal date and products without calling the legacy subscription service.

ProviderWidget helperWidget action helper
Looploop_subscription_widget(context)loop_cancel_widget_subscription(context)
Rechargerecharge_subscription_widget(context)recharge_cancel_widget_subscription(context)
WooCommerce Subscriptionswoocommerce_subscription_widget(context)woocommerce_cancel_widget_subscription(context)

The existing lookup helpers still return raw provider responses. These separate presentation helpers return the sidebar widget contract. No imports are needed. Their full provider-specific source is on the Loop, Recharge and WooCommerce pages; shared presentation and validation source is below.

Set up with Copilot

Ask Copilot: "Create a Loop subscriptions sidebar widget for BUSINESS, with a confirmation button to cancel a subscription. Use the standard Python helpers and keep it inactive until I have reviewed the lookup."

Copilot can use create_sidebar_widget, create_sidebar_widget_action, test_sidebar_widget and update_sidebar_widget. Creating a button does not create its action: both code entries below are required, and the action must belong to that widget. The action is human-operated and is never offered to the bot.

Widgets currently apply to the whole organization. Limit both code entries to the intended business slugs, especially when brands use different providers. Replace your-business below with the real slug. Use the matching provider's two helpers for Recharge or WooCommerce.

Widget code:

BUSINESSES = {"your-business"}


def get_sidebar_data(context):
    if (context.get("business") or {}).get("slug") not in BUSINESSES:
        return {"title": "Subscriptions", "items": []}
    return loop_subscription_widget(context)

Create an action named cancel_subscription, with arguments subscriptionId, businessSlug, customerEmail, and this code:

BUSINESSES = {"your-business"}


def execute_action(context):
    if (context.get("business") or {}).get("slug") not in BUSINESSES:
        raise ValueError("This widget is not enabled for this business.")
    return loop_cancel_widget_subscription(context)

Call loop_subscription_widget(context, cancel_action=None) for a read-only panel. To choose another action name, pass it as cancel_action="your_action_name" and create that action on the widget. Change the widget's returned fields or copy the helper source when you need custom presentation or policy.

What happens on a click

  1. The agent sees a confirmation identifying the plan and subscription ID. Dismissing it does nothing.
  2. After confirmation, the action checks the authenticated agent context, current business and customer email. It looks up this customer's subscriptions again and refuses an ID that is no longer in that list.
  3. Active, paused, on-hold and pending subscriptions can be cancelled. Cancelled subscriptions have no button. A stale click on an already cancelled subscription refreshes the panel without another cancellation request; unknown or expired states are refused.
  4. The provider cancellation runs once. Loop retains notifyCustomer: false, Recharge uses the reason Octocom Dashboard, and WooCommerce sets the status to cancelled. The action does not issue a refund.
  5. The sidebar refreshes after success. Provider errors remain failures. There is no automatic retry; after an uncertain network outcome, inspect the provider or refresh before trying again.

Missing customer email or no matching subscriptions produces an empty panel. A provider failure is shown as an error, not an empty result. Dates are labelled UTC. The shared action lock prevents simultaneous duplicate clicks on the same conversation/action/arguments; it is not provider idempotency across conversations.

Migration boundary

Inspect existing custom widgets before creating another. Install and test the widget inactive first, using the business's correct provider. These helpers do not disable the built-in subscription panel or remove subscription_configurations; coordinate that cutover separately so agents do not see two panels. Orbio's custom provider is not covered by these standard helpers. Shopify order writes, skipping renewals and other subscription changes are outside this step.

Test cancellations only with fake providers or disposable provider test records. test_sidebar_widget runs the lookup code; it does not press a cancellation button. Never use a real customer's subscription as a test cancellation.

Shared source

The provider-specific helpers use these private presentation and validation functions. Include them too if you copy a provider widget helper into your own code.

"""Presentation and click validation shared by the explicit provider widgets.

Provider helpers keep returning raw provider data. Only sidebar helpers build
the widget contract; there is no provider dispatcher or legacy-service call.
"""


def _subscription_widget_identity(context):
    business = (context.get("business") or {}).get("slug") or ""
    email = str((context.get("customer") or {}).get("email") or "").strip()
    return business, email


def _subscription_widget_value(value, unit):
    """Keep missing values hidden, while preserving zero prices and quantities."""
    if value is None or value == "":
        return None
    return f"{value} {unit or ''}".strip()


def _subscription_widget_item(context, subscription_id, title, status, fields, sections, cancel_action):
    business, email = _subscription_widget_identity(context)
    subscription_id = str(subscription_id)
    status = str(status or "unknown").lower()
    actions = []
    if cancel_action and business and email and status in ("active", "paused", "on-hold", "pending"):
        actions.append({
            "label": "Cancel subscription",
            "action": cancel_action,
            "args": {"subscriptionId": subscription_id, "businessSlug": business, "customerEmail": email},
            "confirm": f"Cancel {title} (subscription {subscription_id})? Future renewals will stop. This does not refund previous payments.",
            "variant": "danger",
        })
    return {
        "title": str(title),
        "subtitle": f"Subscription {subscription_id}",
        "badge": {"text": status, "color": "green" if status == "active" else "gray"},
        "actions": actions,
        "sections": [{"title": "Subscription", "fields": {"ID": subscription_id, "Email": email, **fields}}, *sections],
    }


def _subscription_widget_cancel(context, find_subscriptions, cancel_subscription):
    """Re-resolve the target through this conversation's customer before writing."""
    business, email = _subscription_widget_identity(context)
    args = context.get("args") or {}
    subscription_id = str(args.get("subscriptionId") or "").strip()
    if not (context.get("current_user") or {}).get("id"):
        raise ValueError("Subscription cancellation must be initiated by a signed-in agent.")
    if (not business or not email or args.get("businessSlug") != business
            or str(args.get("customerEmail") or "").strip().lower() != email.lower()):
        raise ValueError("The conversation's business or customer changed. Refresh the sidebar before cancelling.")
    if not subscription_id.isascii() or not subscription_id.isdigit():
        raise ValueError("Invalid subscription ID. Refresh the sidebar before cancelling.")
    subscription = next((s for s in find_subscriptions(context, email=email)
                         if str(s.get("id")) == subscription_id), None)
    if subscription is None:
        raise ValueError("This subscription was not found for the conversation's customer. Refresh the sidebar.")
    status = str(subscription.get("status") or "").lower()
    if status in ("cancelled", "canceled", "pending-cancel"):
        return {"message": "This subscription is already cancelled or awaiting cancellation.", "refresh": True}
    if status not in ("active", "paused", "on-hold", "pending"):
        raise ValueError("This subscription cannot be cancelled in its current state. Refresh the sidebar.")
    # No retry: a transport failure can occur after the provider accepts a write.
    cancel_subscription(context, subscription_id)
    return {"message": "Subscription cancelled successfully.", "refresh": True}

On this page