All pages

Webhooks

An event asks the api to refresh an object. Stripe handlers take its ID from the event, retrieve the object, and write the retrieved state. Clerk creation and update events follow the same pattern. Deletion uses the event ID because the deleted user can no longer be retrieved.

A replay refreshes state instead of restoring an old payload. This reduces delivery- ordering problems, but concurrent fetch/write cycles can still race: an older fetch can be written after a newer one. A dropped event leaves state stale until another trigger. Neither case has a reconciliation timer.

The two endpoints

Endpoint Verified by Body Guarded by
POST /stripe/webhook Stripe signature, STRIPE_WEBHOOK_SECRET raw Buffer the signature
POST /clerk/webhook svix, CLERK_WEBHOOK_SECRET raw Buffer the signature

Neither router carries an authentication guard, because for a webhook the signature is the credential. That is why each is its own router rather than an unguarded block at the top of a guarded one: there is no line in either file that a later .use() could accidentally protect or expose.

Neither router carries a rate limiter either. Throttling a webhook provider's retries is how you lose events.

Raw bodies, and where that is arranged

Both vendors compute a signature over the exact bytes they sent, so a JSON parse upstream of verification breaks it. The parser is applied at the mount, in apps/api/src/index.ts, above express.json():

app.use(
  '/stripe',
  express.raw({ type: 'application/json', limit: WEBHOOK_BODY_LIMIT }),
  stripeRouter,
)
app.use(
  '/clerk',
  express.raw({ type: 'application/json', limit: WEBHOOK_BODY_LIMIT }),
  clerkRouter,
)

app.use(express.json({ limit: BODY_LIMIT }))

req.body inside either handler is a Buffer, and no handler has to arrange that for itself. The reference implementation mounted /stripe as the very first middleware and then re-mounted express.json(), clerkMiddleware() and its auth guard per route to undo it, which is what getting the mount order right avoids.

The webhook limit is 1mb where the rest of the api is 100kb. A Stripe event carrying a long line-item list is legitimately larger than anything a browser posts, and rejecting one loses an event.

Note

express.raw() only produces a Buffer when the content type matched. A request arriving as anything else leaves req.body an empty object, which would be verified as the string [object Object] and fail with a signature error naming the wrong problem. rawBody() in apps/api/src/lib/webhooks/signed-request.ts narrows it and answers with the same invalid_signature code as every other verification failure.

Every way verification can fail answers with one code. A missing header, a wrong secret and a tampered body responding differently would tell a forger which half they got wrong.

The events, and why the list is short

Stripe:

Event What the handler does
checkout.session.completed re-fetch the Session, record a purchase or sync the subscription
checkout.session.async_payment_succeeded the same, and the only signal that a delayed-notification payment settled
customer.subscription.created re-fetch the Subscription, upsert on its own id, and mail the owner when the status revokes
customer.subscription.updated the same branch
customer.subscription.deleted the same branch
charge.succeeded retrieve and validate the charge, then claim the receipt attempt
charge.refunded re-fetch the charge and write its refund state onto the purchase
invoice.payment_failed re-fetch the invoice's subscription, and warn its owner when it reads past_due
customer.subscription.trial_will_end re-fetch the Subscription, and warn its owner what the first charge will be
anything else acknowledged and ignored

The three customer.subscription.* events share one branch because, once the handler re-fetches, there is nothing left for them to do differently. A canceled subscription still retrieves, with status: 'canceled' and canceled_at set, so the same upsert records the cancellation. That branch is also where the revocation mail goes out, after the write: a status of canceled or unpaid has just dropped the row out of user_entitlements, and no other event reports that. The mail claims email_deliveries on the subscription id, so the redeliveries these three events produce send one mail between them. Subscription cancellation retains billing history. Clerk account deletion currently cascades into both payment tables, so it has a different retention outcome.

invoice.paid is absent. It existed in the reference to flip a subscription's status, and Stripe already emits customer.subscription.updated for every status change.

invoice.payment_failed is subscribed for the mail rather than for the status. It writes nothing: the handler re-fetches the invoice's subscription and sends the past-due warning only when that subscription currently reads past_due, so incomplete and incomplete_expired mail nothing. It fires once per Smart Retry, which is why the mail takes a claim keyed on the invoice id rather than on the event. See docs/adr/0032-mail-follows-entitlement-not-invoices.md.

customer.subscription.trial_will_end is subscribed for the same reason and writes nothing either. Stripe fires it three days before a trial converts, so the mail claims the subscription id, and the handler sends only while the re-fetched subscription still reads trialing. No shipped offer sets trialDays, so Stripe never sends this event until one does, and subscribing it unconditionally costs a case in a switch. Add a plan has the field.

Clerk sends user.created, user.updated and user.deleted. The first two refresh the user and then flush Fulfillment. They no longer link anything: a Purchase is bound to its owner before the charge, so this handler is identity work only. The flush stays because a Purchase parked on a missing GitHub username waits on nothing else. The third deletes the stored user by the event ID.

Unhandled event types get a 2xx. Stripe and svix both send many, and answering 4xx makes them mark the endpoint unhealthy and back off the events that matter.

The 2xx is written last

This is the defect most worth understanding, because the code this replaces had it.

export const postWebhook: RequestHandler = async (req, res) => {
  const event = await verifiedEvent(req)

  switch (event.type) {
    // every branch either resolves its write or throws
  }

  res.json({ received: true })
}

The reference caught every error inside every branch and answered { received: true } regardless. A failed write was acknowledged as processed, Stripe never sent it again, and the result is a buyer who paid with no row, no invite, and no event left to replay.

Here anything that throws travels to the error middleware, the response is a 5xx, and the event stays queued for redelivery. Express 5 forwards a rejected promise on its own, so there is no asyncHandler wrapper anywhere in this app.

There is no stripe_events table

Stripe's own guidance is to record processed event ids so you can spot duplicates. This implementation instead upserts refreshed objects by their own IDs. That avoids duplicate rows without maintaining a processed-event ledger, but it does not order concurrent snapshots or recover missed events.

External operations need their own controls. fulfillment_state gives one handler the invite attempt; a row in email_deliveries gives one handler each mail, keyed on its kind and the Stripe object it reports on. A claim is not proof of delivery. Mail remains best-effort after its claim, and a process failure can strand one Purchase as in_progress until admin requeue.

What this costs you

One extra API call per event, because the handler re-fetches. At FFD's volume that is free. At high volume, add a table keyed on the Stripe event id and skip ids you have already seen, which is the point where the tradeoff flips.

A dropped event leaves state stale. Stripe retries for up to three days and svix retries on its own schedule, so a genuine drop is rare, and when it happens recovery is a human noticing. What that costs, and why there is no reconciliation timer to shorten it, is in The billing model.

Local development needs a tunnel. stripe listen --forward-to localhost:5001/stripe/webhook gives you a signing secret for the session. Clerk's webhook needs a public URL, which is why CLERK_WEBHOOK_SECRET is optional and requireUser creates the row lazily: without that, no buyer could run this locally without tunnelling.

Where the decisions live

  • docs/adr/0004-webhook-as-trigger.md, re-fetching instead of writing payloads
  • docs/adr/0007-atomic-claim-for-side-effects.md, exclusive claims for side effects
  • docs/adr/0016-positional-middleware-that-only-tightens.md, why the webhook routers stand alone

What happens after a payment is recorded is Fulfillment.

Updated