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 covers the in-app
surface; emails are entirely yours.
Event types
| Event | When it fires | Common reactions |
|---|---|---|
subscription.created | A new subscription row is inserted (auto from checkout, or via API). | 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. | Ledger entry. Receipt email (optional). |
subscription.payment_failed | Every failed renewal attempt. Includes attempt, nextRetryAt, and lastError in the payload. | Payment-failed email. First failure is the most important. |
subscription.past_due | The first time a subscription transitions to past_due (after 3 failures). | Final-warning email with update-card link. |
subscription.updated | Plan, amount, interval, or metadata changed via PATCH, or a pending plan change was cleared via DELETE …/pending-change. | 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. | 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.trial_blocked | Auto-create downgraded a trial-requested subscription to no-trial because the card fingerprint matched a prior trial on this merchant. Fires alongside subscription.created. data.reason is one of card_already_used_for_trial, payment_method_not_found, or no_fingerprint_available. | 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. | 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,
"metadata": {}
},
"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
}
}
} 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':
// Send 'your payment failed' email. Fires every retry attempt.
// Top-level payload fields: attempt, nextRetryAt, lastError.
await sendDunningEmail(event.data.subscription, event.data.attempt);
break;
case 'subscription.past_due':
// First time the sub crosses the 3-failure threshold. Use this for the
// 'last warning' email — payment_failed fires every attempt and would
// spam buyers if you treated each one as an escalation.
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;
}
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.payment_failedfires on every attempt;past_duefires once. Use the latter for the final warning, not the thirdpayment_failed. -
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. - 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.