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 does not send buyer emails
All notifications flow through your webhook endpoint. You decide which events translate to a buyer-facing message and which provider (Resend, Postmark, SES) delivers it. The <DunningBanner /> component covers the in-app surface; emails are entirely yours.

Event types

EventWhen it firesCommon reactions
subscription.createdA new subscription row is inserted (auto from checkout, or via API).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.Ledger entry. Receipt email (optional).
subscription.payment_failedEvery failed renewal attempt. Includes failureCount in payload.Payment-failed email. First failure is the most important.
subscription.past_dueThe first time a subscription transitions to past_due (after 3 failures).Final-warning email with update-card link.
subscription.updatedPlan, 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_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.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.trial_blockedAuto-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.vaultedA 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.

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,
      "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.

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
    }
  }
}

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':
      // 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_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 failureCount to branch.
  • Missing the past_due escalation. payment_failed fires on every attempt; past_due fires once. Use the latter for the final warning, not the third payment_failed.
  • 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.