Subscriptions

Subscription Webhooks

Webhooks are the source of truth for subscription state. Your DB stays in sync without polling, and you decide which events translate to buyer emails or in-app notifications.

Throttle already sends several buyer emails
Throttle sends transactional emails to buyers automatically for the events that matter most — welcome, renewal receipt, renewal reminder, payment-failed, cancellation, plan-changed, and paid-in-full — so you don't need to build these from webhooks. See Lifecycle and States for the full list. Webhooks are still how you drive anything beyond that: your own additional buyer-facing message, delivered by your own provider (Resend, Postmark, SES), or an in-app surface like the <DunningBanner /> component.

Event types

EventWhen it firesCommon reactions
subscription.createdA new subscription row is inserted (auto from checkout, or via API). A checkout that created several subscriptions fires one per subscription.Provision access. Send welcome email.
subscription.activatedA trialing subscription flips to active at trialEnd.Optional "your trial ended, you are now subscribed" email.
subscription.renewedA renewal charge succeeded and the period advanced. Carries data.payment (the public payment) and data.order when a charge was made.Ledger entry. Receipt email (optional).
subscription.backup_pm_usedThe card the renewal tried first was declined and another of the buyer's cards paid. data.backupPaymentMethod is { cardBrand, cardLastFour } of the card that paid. data.repinned: true when the subscription's own card hard-declined (expired, an invalid card number, lost or stolen): the subscription now renews on the card that paid, and data.subscription.paymentMethodId is that card. No separate subscription.updated is sent for that move.Tell the buyer which card was charged; on repinned, that it is now the card for this subscription.
subscription.invoice_refundedOne billing cycle was refunded. data.intent is money_only (the subscription keeps billing) or refund_and_cancel (it was cancelled too). data.amountRefunded is what this refund moved, data.totalRefunded is the cycle's running total, and data.fullyRefunded says whether the cycle is now whole. The same event is recorded in the event feed (GET /api/v1/events) with entityType: "subscription" and entityId set to the subscription id, so ?entityType=subscription&entityId=<id> finds it. It is emitted for both refund routes: the dedicated cycle refund and cancel with a refund.Ledger entry and refund receipt. Do not revoke access on money_only — wait for subscription.cancelled.
subscription.payment_failedEach of the first three failed renewal attempts (the fourth cancels instead). Includes attempt, nextRetryAt, and lastError in the payload.Payment-failed email. First failure is the most important.
subscription.past_dueThe subscription moved to past_due: fires once, on the first failed renewal attempt.Mark the account past due and show an update-card prompt.
subscription.updatedPlan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change. Also fires when the card a subscription renews on changes: the merchant picks a card for it (PATCH /api/v1/subscriptions/:id/payment-method, or Change card in the dashboard), the buyer does (PATCH /v1/storefront/me/subscriptions/:id/payment-method, or PATCH /api/v1/me/subscriptions/:id/payment-method from the billing link), or a new default card moves the subscriptions that were on the previous default. Picking a card for one subscription fires once, for that subscription only; through the merchant or storefront route, picking the card it already has fires nothing. data.subscription.paymentMethodId is the new card. Deleting a card a subscription uses does not fire it: the subscription silently falls back to the default card.Confirmation email if buyer-initiated.
subscription.plan_change_scheduledA plan change was deferred to the next renewal via POST …/change-plan with effective: "period_end". The data.pending object contains the new plan details, and data.effectiveAt is the period-end ISO timestamp.Notify the buyer: "your plan change to X takes effect on [date from effectiveAt]". Show the pending change in your billing UI. If the change is later cancelled via DELETE …/pending-change, subscription.updated fires.
subscription.plan_changedA plan change was applied. Fires in two situations: (1) an immediate upgrade ( effective: "now") charged and applied right away, or (2) a scheduled downgrade applied by the renewal cron at period end. In both cases, the data.subscription payload reflects the new plan fields and all pending* fields are null. An immediate change also carries data.proration (creditCents, chargedCents, fullAmount).Update your local plan cache. Send a plan-change confirmation email. Grant or revoke feature access matching the new plan.
subscription.pausedSubscription transitioned to paused.Suspend feature access (or leave on, depending on your model).
subscription.resumedSubscription transitioned back to active.Re-enable access.
subscription.cancelledTerminal cancellation. data.reason is one of merchant_action, dunning_exhausted, or period_end.De-provision. Send subscription-ended email.
subscription.completedTerminal. An installment plan's final payment (totalPayments) succeeded — the moment payment N clears, not the end of the period it covers. Requires the subscriptions:read scope, same as every other subscription.* event.Treat completed as paid in full even if your access gate only checks status = active. Send a "paid in full" confirmation.
subscription.trial_blockedTrial-fraud protection refused a requested trial because the card already had one on this merchant. Usually checkout refuses it (409 trial_not_available): no subscription exists, so data.subscriptionId is null and cartId, checkoutSessionId and planReference name the checkout. When two checkouts race with the same card, the post-payment check catches it instead: the subscription id is set, it fires alongside subscription.created, and that subscription's trial ends at once. On a refusal data.customerId is null for a guest buyer with no customer yet, and data.paymentMethodId may be null. data.reason is the eligibility reason, normally card_already_used_for_trial. See Trial Fraud Protection.Notify the buyer (optional). Log the decision for fraud analytics.
payment.vaultedA card was successfully vaulted in a checkout session. Carries recurring metadata when the session had it — including intervalUnit, intervalCount, and totalPayments when the session specified a custom cadence or an installment plan. Absent when the cart's lines carry the subscriptions: read one subscription.created per line instead.Use as a hook point for Pattern C (webhook-driven subscription creation).

Example payload

All subscription events use the same envelope shape with data.subscription containing the current subscription state. Most events carry only subscription; plan-change events include additional fields: pending + effectiveAt for scheduled changes, or previous for completed changes.

Every subscription event also carries a sibling data.customer object so you can map the event to your own tenant without a follow-up API call. It holds two different external identifiers, and which one you want depends on how you created the customer:

  • externalId — your own customer id, set via externalId on POST /customers or customer.externalCustomerId on a checkout session. This is the one most integrations key their account lookup on.
  • externalCustomerId — a per-connection mapping, set only when the customer arrived through a platform connection. Usually null for direct API integrations.

Both are null when never set, and data.customer itself is omitted if the customer record cannot be resolved — so read it defensively rather than assuming it is present. Note that data.subscription has no external-id field of its own; it carries only the internal customerId.

json
{
  "id": "evt_01HF8...",
  "type": "subscription.renewed",
  "workspaceId": "merch_xyz",
  "createdAt": "2026-05-03T12:00:00Z",
  "data": {
    "subscription": {
      "id": "sub_abc",
      "customerId": "cus_xyz",
      "status": "active",
      "planReference": "pro_monthly",
      "planName": "Pro Monthly",
      "interval": "monthly",
      "amount": 2999,
      "currency": "USD",
      "currentPeriodStart": "2026-05-03T12:00:00Z",
      "currentPeriodEnd": "2026-06-03T12:00:00Z",
      "trialEnd": null,
      "failureCount": 0,
      "cancelAtPeriodEnd": false,
      "pendingPlanReference": null,
      "pendingPlanName": null,
      "pendingInterval": null,
      "pendingAmount": null,
      "paymentMethodId": "6b1f0c2e-8d4a-4f3b-9a1e-2c7d5e8f9a10",
      "metadata": {}
    },
    "payment": {
      "id": "pay_123",
      "orderId": "ord_456",
      "status": "captured",
      "method": "card",
      "processor": "embedded",
      "processorTransactionId": "txn_789",
      "amount": 3247,
      "capturedAmount": 3247,
      "currency": "USD",
      "cardBrand": "visa",
      "cardLastFour": "4242",
      "capturedAt": "2026-05-03T12:00:04Z",
      "createdAt": "2026-05-03T12:00:02Z"
    },
    "order": { "id": "ord_456", "orderNumber": "1042", "total": 3247, "currency": "USD" },
    "customer": {
      "id": "cus_xyz",
      "email": "[email protected]",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "phone": null,
      "externalId": "your-user-42",
      "externalCustomerId": null
    }
  }
}

Plan change scheduled (downgrade deferred)

When a plan change is scheduled for the next period end, data.pending holds the new plan details and data.effectiveAt is the ISO date when it will apply.

json
// subscription.plan_change_scheduled — payload when a downgrade is deferred
{
  "id": "evt_02AB9...",
  "type": "subscription.plan_change_scheduled",
  "workspaceId": "merch_xyz",
  "createdAt": "2026-06-01T10:00:00Z",
  "data": {
    "subscription": {
      "id": "sub_abc",
      "status": "active",
      "planReference": "pro_monthly",
      "interval": "monthly",
      "amount": 2999,
      "currentPeriodEnd": "2026-06-03T12:00:00Z"
    },
    "pending": {
      "planReference": "starter_monthly",
      "planName": "Starter (Monthly)",
      "interval": "monthly",
      "amount": 999
    },
    "effectiveAt": "2026-06-03T12:00:00Z"
  }
}

Plan changed (upgrade applied or downgrade finalized)

When a plan change completes (immediately or at renewal), data.subscription reflects the new plan and data.previous captures the old plan reference and amount.

json
// subscription.plan_changed — payload after upgrade or deferred downgrade applies
{
  "id": "evt_03CD0...",
  "type": "subscription.plan_changed",
  "workspaceId": "merch_xyz",
  "createdAt": "2026-06-03T12:00:00Z",
  "data": {
    "subscription": {
      "id": "sub_abc",
      "status": "active",
      "planReference": "starter_monthly",
      "planName": "Starter (Monthly)",
      "interval": "monthly",
      "amount": 999,
      "currentPeriodStart": "2026-06-03T12:00:00Z",
      "currentPeriodEnd": "2026-07-03T12:00:00Z"
    },
    "previous": {
      "planReference": "pro_monthly",
      "amount": 2999
    }
  }
}

Completed (installment plan paid in full)

data.subscription.totalPayments and paymentsMade are equal, and completedAt is set. This is the exact payload POST /api/v1/webhook-endpoints/:id/test sends for this event type — the send-test fixture uses a plain monthly interval and an illustrative 3-payment schedule.

json
{
  "id": "1a2b3c07-0000-4000-8000-000000000000",
  "type": "subscription.completed",
  "version": "1",
  "createdAt": "2026-09-10T12:00:00.000Z",
  "environmentId": "11111111-1111-4111-8111-111111111111",
  "environmentKind": "production",
  "workspaceId": "22222222-2222-4222-8222-222222222222",
  "data": {
    "subscription": {
      "id": "test_sub_1a2b3c04",
      "customerId": "test_cust_1a2b3c02",
      "status": "completed",
      "planReference": "test-plan",
      "interval": "monthly",
      "amount": 2000,
      "currency": "USD",
      "paymentSchedule": [
        {
          "index": 0,
          "amount": 4000,
          "discount": 0,
          "net": 4000,
          "intervalUnit": null,
          "intervalCount": null,
          "status": "paid",
          "dueAt": null
        },
        {
          "index": 1,
          "amount": 2500,
          "discount": 0,
          "net": 2500,
          "intervalUnit": "month",
          "intervalCount": 1,
          "status": "paid",
          "dueAt": null
        },
        {
          "index": 2,
          "amount": 3500,
          "discount": 0,
          "net": 3500,
          "intervalUnit": "month",
          "intervalCount": 1,
          "status": "paid",
          "dueAt": null
        }
      ],
      "scheduleTotal": 10000,
      "nextPaymentAmount": null,
      "totalPayments": 3,
      "paymentsMade": 3,
      "completedAt": "2026-09-10T12:00:00.000Z"
    },
    "payment": {
      "id": "test_pay_1a2b3c01",
      "orderId": "test_ord_1a2b3c00",
      "status": "authorized",
      "method": "card",
      "processor": "embedded",
      "amount": 100,
      "capturedAmount": 100,
      "currency": "USD",
      "cardBrand": "visa",
      "cardLastFour": "4242",
      "createdAt": "2026-09-10T12:00:00.000Z"
    },
    "order": {
      "id": "test_ord_1a2b3c00",
      "orderNumber": "TEST-1001",
      "total": 100,
      "currency": "USD",
      "status": "pending",
      "paymentStatus": "pending",
      "source": "throttle-test",
      "subscriptionId": null,
      "subscriptionIds": []
    },
    "paymentIndex": 2,
    "customer": {
      "id": "test_cust_1a2b3c02",
      "email": "[email protected]",
      "firstName": "Test",
      "lastName": "Buyer",
      "phone": null,
      "externalId": "test_ext_1a2b3c09",
      "externalCustomerId": "test_conn_1a2b3c0a"
    }
  }
}

A real installment plan on a custom cadence carries intervalUnit / intervalCount instead of a named interval — illustrated below (not a send-test output):

json
// ILLUSTRATION ONLY — a real installment plan on a custom cadence.
// Not what send-test sends (its fixture is always a plain monthly interval,
// see the payload above); this shows the intervalUnit/intervalCount shape
// a genuine custom-cadence installment plan carries in data.subscription.
{
  "id": "evt_05GH2...",
  "type": "subscription.completed",
  "workspaceId": "merch_xyz",
  "createdAt": "2026-09-26T12:00:00Z",
  "data": {
    "subscription": {
      "id": "sub_abc",
      "customerId": "cus_xyz",
      "status": "completed",
      "planReference": "biweekly-installment",
      "interval": "custom",
      "intervalUnit": "day",
      "intervalCount": 15,
      "totalPayments": 4,
      "paymentsMade": 4,
      "completedAt": "2026-09-26T12:00:00Z",
      "amount": 10000,
      "currency": "USD"
    },
    "customer": {
      "id": "cus_xyz",
      "email": "[email protected]",
      "firstName": "Ada",
      "lastName": "Lovelace",
      "phone": null,
      "externalId": "your-user-42",
      "externalCustomerId": null
    }
  }
}

Installment plans: paymentIndex and the schedule fields

Every subscription.* payload's data.subscription carries paymentSchedule, scheduleTotal and nextPaymentAmount (all null on an ongoing subscription), and never raw database columns. On an installment plan, subscription.renewed and subscription.completed also carry data.paymentIndex, the 0-based index of the payment just made. Webhooks sent by the renewal engine use the same public subscription shape as the API. A custom schedule's recurring.schedule rides subscription.create_failed; payment.vaulted carries only a session recurring intent, which never has a schedule. See Installment Plans.

The card and the payment

data.subscription.paymentMethodId is the saved card the subscription renews on: the card used at its checkout, or the one the buyer picked for it later. null means it renews on the customer's default card. subscription.renewed, subscription.completed and subscription.backup_pm_used carry the charge as data.payment in the public payment shape; its amount includes tax.

Reference handler

ts
// app/api/webhooks/throttle/route.ts
import { verifyThrottleWebhook } from '@/lib/throttle/webhooks';
export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get('x-throttle-signature');
  if (!signature || !verifyThrottleWebhook(rawBody, signature, process.env.THROTTLE_WEBHOOK_SECRET!)) {
    return new Response('Invalid signature', { status: 400 });
  }
  const event = JSON.parse(rawBody);
  switch (event.type) {
    case 'subscription.created':
      await provisionAccess(event.data.subscription);
      break;
    case 'subscription.activated':
      // Trial ended. Charging starts now.
      break;
    case 'subscription.renewed':
      // Optional ledger entry for the new period.
      break;
    case 'subscription.payment_failed':
      // Fires on each of the first three failed attempts (the fourth cancels).
      // Top-level payload fields: attempt, nextRetryAt, lastError.
      // attempt 3 is the last warning: one retry is left.
      await sendDunningEmail(event.data.subscription, event.data.attempt);
      break;
    case 'subscription.past_due':
      // Fires once, on the FIRST failed renewal attempt, when the status
      // changes to past_due. Use it to flip your access/UI state and show an
      // update-card prompt — not as the final warning.
      await markPastDue(event.data.subscription);
      break;
    case 'subscription.paused':
    case 'subscription.resumed':
    case 'subscription.updated':
      await invalidateLocalCache(event.data.subscription.id);
      break;
    case 'subscription.plan_change_scheduled':
      // A downgrade (effective: 'period_end') was scheduled.
      // event.data.pending holds the new plan details; event.data.effectiveAt is the ISO date.
      await notifyBuyerOfScheduledChange(event.data.pending, event.data.effectiveAt);
      break;
    case 'subscription.plan_changed':
      // An upgrade (effective: 'now') completed, or a scheduled downgrade applied at renewal.
      // event.data.subscription reflects the new plan; event.data.previous holds the old plan.
      await updateBuyerPlanAccess(event.data.subscription, event.data.previous);
      break;
    case 'subscription.cancelled':
      // event.data.reason is one of:
      //   'merchant_action' — cancel API or dashboard
      //   'dunning_exhausted' — hit retry cap
      //   'period_end' — atPeriodEnd cancel finalized
      await deprovisionAccess(event.data.subscription, event.data.reason);
      break;
    case 'subscription.completed':
      // An installment plan's final payment succeeded. Treat this as paid in
      // full even if your access check only looks for status === 'active'.
      await markInstallmentPlanPaidInFull(event.data.subscription);
      break;
  }
  return Response.json({ ok: true });
}

Delivery semantics

  • At-least-once delivery. Throttle retries on non-2xx responses for up to 24 hours. Make your handler idempotent — track event IDs and skip duplicates.
  • Out-of-order possible. Network retries can reorder events. Don't rely on event ordering for critical state — check the subscription's current row before acting.
  • Signature verification required. Every event is signed. Reject unsigned or invalid-signature requests — see the Webhooks page for the verification recipe.

Common pitfalls

  • Treating every payment_failed as urgent. The first failure deserves the loud "your card was declined" email. The second and third should escalate copy, not repeat the same message. Use the top-level attempt to branch.
  • Missing the past_due escalation. past_due fires once, on the first failed attempt, when the status changes — use it to flip access and UI state. payment_failed fires on attempts 1–3; attempt 3 is the final warning, because the next failure cancels.
  • Forgetting dunning_exhausted. When a subscription auto-cancels after retries are exhausted, subscription.cancelled fires with reason: 'dunning_exhausted'. Branch on reason if your offboarding email differs from a buyer-initiated cancel.
  • Reading externalCustomerId instead of externalId. Both live on the same customer object. externalId is the identifier you set; externalCustomerId is a per-connection mapping and is null for direct API integrations. Reading the wrong one resolves to nothing on every event while your endpoint returns 200, so the delivery log shows a healthy stream of successes and nothing happens.
  • Gating access on status = active alone. An installment plan moves to completed, not active, the moment its final payment succeeds — it will never bill again, but it is paid in full. A gate that only allows active locks out a buyer who paid for everything they owed. Listen for subscription.completed too.
  • Handling only subscription.cancelled. A period-end cancellation does not emit it. Cancelling with atPeriodEnd: true emits subscription.updated with cancelAtPeriodEnd: true, while status stays active and cancelledAt stays null. Miss it and the buyer cancels, sees no confirmation, and cancels again.

Both of these produce no error anywhere. See Failures with no error.