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.
<DunningBanner />
component.
Event types
| Event | When it fires | Common reactions |
|---|---|---|
subscription.created | A 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.activated | A trialing subscription flips to active at trialEnd. | Optional "your trial ended, you are now subscribed" email. |
subscription.renewed | A 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_used | The 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_refunded | One 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_failed | Each 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_due | The 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.updated | Plan, 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_scheduled | A 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_changed | A 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.paused | Subscription transitioned to paused. | Suspend feature access (or leave on, depending on your model). |
subscription.resumed | Subscription transitioned back to active. | Re-enable access. |
subscription.cancelled | Terminal cancellation. data.reason is one of merchant_action, dunning_exhausted, or period_end. | De-provision. Send subscription-ended email. |
subscription.completed | Terminal. 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_blocked | Trial-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.vaulted | A 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 viaexternalIdonPOST /customersorcustomer.externalCustomerIdon 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. Usuallynullfor 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.
{
"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.
// 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.
// 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.
{
"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):
// 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
// 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_failedas 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-levelattemptto branch. -
Missing the
past_dueescalation.past_duefires once, on the first failed attempt, when the status changes — use it to flip access and UI state.payment_failedfires 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.cancelledfires withreason: 'dunning_exhausted'. Branch onreasonif your offboarding email differs from a buyer-initiated cancel. -
Reading
externalCustomerIdinstead ofexternalId. Both live on the samecustomerobject.externalIdis the identifier you set;externalCustomerIdis 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 = activealone. An installment plan moves tocompleted, notactive, the moment its final payment succeeds — it will never bill again, but it is paid in full. A gate that only allowsactivelocks out a buyer who paid for everything they owed. Listen forsubscription.completedtoo. - Handling only
subscription.cancelled. A period-end cancellation does not emit it. Cancelling withatPeriodEnd: trueemitssubscription.updatedwithcancelAtPeriodEnd: true, whilestatusstaysactiveandcancelledAtstays 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.