pealdevDOCS
Back to peal.dev

STRIPE STARTER · EVENT HANDLERS

Event handlers

Four typed stubs in src/lib/stripe-events/. They receive the narrowed Stripe object and a TODO comment. Implement the body — that's where your business logic lives.

Every handler is async, takes the typed Stripe object, returns void. Throwing returns 500 to Stripe (which retries). Returning normally returns 200 (Stripe stops). The handler body is the only place in this template where you write "business logic that runs after a payment" — DB writes, emails, access provisioning, all here.

The 4 events and what they mean

Terminal
checkout.session.completed
  Fired ONCE when a customer finishes Checkout.
  Use for: provisioning access, fulfillment, welcome email.

invoice.paid
  Fired EVERY billing cycle for subscriptions (first + every renewal).
  Use for: extending access, logging payments, sending receipts.

customer.subscription.updated
  Plan change, status change, cancel-at-period-end flip.
  Use for: syncing plan tier, detecting cancellation intent.

customer.subscription.deleted
  Subscription period ENDED after a cancellation.
  Use for: revoking access, churn email.

onCheckoutSessionCompleted

TypeScript
export async function onCheckoutSessionCompleted(
  session: Stripe.Checkout.Session
): Promise<void> {
  const customerId    = getCustomerId(session.customer);    // string | null
  const customerEmail = session.customer_details?.email;
  const mode          = session.mode; // "payment" | "subscription" | "setup"

  if (mode === "subscription") {
    const subscriptionId = getResourceId(session.subscription);
    // upsert subscription, link customerId → user, send welcome
  } else if (mode === "payment") {
    const paymentIntentId = getResourceId(session.payment_intent);
    // grant access to the purchased product
  }
}

onInvoicePaid

TypeScript
export async function onInvoicePaid(invoice: Stripe.Invoice): Promise<void> {
  const customerId     = getCustomerId(invoice.customer);
  const subscriptionId = invoice.parent?.subscription_details?.subscription ?? null;
  const amountPaid     = invoice.amount_paid; // cents

  // Extend access window, store the invoice, send receipt
}

onSubscriptionUpdated — the trickiest one

TypeScript
export async function onSubscriptionUpdated(
  subscription: Stripe.Subscription
): Promise<void> {
  const customerId        = getCustomerId(subscription.customer);
  const status            = subscription.status;
  const cancelAtPeriodEnd = subscription.cancel_at_period_end;
  const priceId           = subscription.items.data[0]?.price.id;
  const planId            = subscription.metadata.planId; // set by createCheckoutSession

  // Sync plan tier, detect upgrade/downgrade,
  // flag "will cancel soon" UI when cancelAtPeriodEnd flips true.
}

onSubscriptionDeleted

TypeScript
export async function onSubscriptionDeleted(
  subscription: Stripe.Subscription
): Promise<void> {
  const customerId     = getCustomerId(subscription.customer);
  const subscriptionId = subscription.id;

  // Revoke access NOW — the user paid through the end of the period,
  // and that period is over.
}

subscription.updated fires for clicking "cancel" — deleted fires when the period ends

When a user cancels, Stripe doesn't end the subscription immediately — they paid for the rest of the period. .updated fires with cancel_at_period_end=true (so you can show "cancels on X" in your UI). When the period ends, .deleted fires (revoke access then). Don't revoke on .updated unless status flips to canceled.

Webhooks can fire more than once. Build idempotent handlers.

Network blips, retries, replays — assume every handler may see the same event twice. The safe pattern is: use event.id (or a deterministic key like subscriptionId + status) and check "have I already processed this?" before writing. The template doesn't ship a dedupe table because your DB choice is yours — but build one before launch.

checkout.session.async_payment_succeeded — for ACH/SEPA

If you accept bank-debit methods, checkout.session.completed fires when the session ends, BEFORE the money actually arrives. The real fulfillment trigger is checkout.session.async_payment_succeeded. We don't ship a handler for it; add one when you turn on those payment methods.