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 failureCount in 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.
{
"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": {}
}
}
}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.
await sendDunningEmail(event.data.subscription, event.data.failureCount);
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. UsefailureCountto 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.