Manual Payments The Webhook

The Webhook

elements man payments/webhook Read as markdown

Stripe POSTs events to the app. For a one-time payment the webhook is the backstop for a buyer who never comes back to the return page. For a subscription it is the only way to hear about renewals, failed cards and cancellations made in Stripe.

Where the Secret Comes From

  • Development: stripe listen --forward-to localhost:<port>/stripe/webhook prints a signing secret. It goes in STRIPE_WEBHOOK_SECRET. Optional: without it, the return page still fulfills payments.
  • Production: the app registers its own endpoint through the Stripe API, the first time a buyer starts a checkout, and stores the secret it gets back. Stripe returns an endpoint's secret only when it is created.
Terminal
elements create migration "add stripe webhooks" -tables=stripeWebhooks
app/migrations/20260509120000-add-stripe-webhooks.migration.sql
create table stripeWebhooks ( url text primary key, createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), endpointId text not null, secret text not null );

One row per url the app has served from. A domain change adds a row and leaves the old endpoint in place, so a cutover where both domains serve traffic loses no events.

app/shared/stripe-webhook.ts
import { checkoutDb, getAppUrl, getEnv, sql } from "@elements/app"; import config from "#config"; import { stripe } from "#app/shared/stripe"; const EVENTS = [ "checkout.session.completed", "checkout.session.async_payment_succeeded", "invoice.paid", "customer.subscription.updated", "customer.subscription.deleted", ] as const; const DESCRIPTION = "Registered by the app"; let ensured = ""; function webhookUrl(): string { return `${getAppUrl()}/stripe/webhook`; } /** * Makes sure Stripe has an endpoint for this app's url, once per process. * A Postgres advisory lock keeps two machines from registering at once. */ export async function ensureWebhook(): Promise<void> { let url = webhookUrl(); if (getEnv() !== "production" || ensured === url) { return; } using db = checkoutDb(); db.sql(`select pg_advisory_lock(hashtext('stripe-webhook'))`); try { let saved = db.sql<{ url: string }>(` select url from stripeWebhooks where url = ${url} `).first(); if (!saved) { // An endpoint at this url with no saved secret is left over from a // reset database. Its secret is gone, so replace it. let existing = await stripe().webhookEndpoints.list({ limit: 100 }); for (let old of existing.data) { if (old.url === url && old.description === DESCRIPTION) { await stripe().webhookEndpoints.del(old.id); } } let endpoint = await stripe().webhookEndpoints.create({ url, enabled_events: [...EVENTS], description: DESCRIPTION, }); db.sql(` insert into stripeWebhooks ( url, endpointId, secret ) values ( ${url}, ${endpoint.id}, ${endpoint.secret!} ) `); } ensured = url; } finally { db.sql(`select pg_advisory_unlock(hashtext('stripe-webhook'))`); } } export function webhookSecret(): string { if (getEnv() !== "production") { return config.stripe.webhookSecret; } return sql<{ secret: string }>(` select secret from stripeWebhooks where url = ${webhookUrl()} `).first()?.secret ?? ""; }

startCheckout (payments/checkout) awaits ensureWebhook() before it creates a session, so the endpoint exists before the first buyer can pay, and nobody has to visit a page or run a step after a deploy. After the first call in a process it returns without a query. A Stripe error throws out of the checkout rpc, and the buyer sees it instead of paying into an app that cannot hear back. Subscriptions start through Checkout too, so renewals are covered from the first one.

The Route

The route verifies the signature against the raw body, which Elements keeps on req.bodyBuffer next to the parsed req.body (elements man router). The signature covers the exact bytes Stripe sent; a re-serialized req.body does not match them.

app/pages/stripe-webhook/index.ts
import { Request, Response } from "@elements/app"; import { stripe } from "#app/shared/stripe"; import { webhookSecret } from "#app/shared/stripe-webhook"; import { fulfillCheckout } from "#app/shared/checkout"; export default async function route(req: Request, res: Response) { let event; try { event = stripe().webhooks.constructEvent( req.bodyBuffer!, req.headers["stripe-signature"] as string, webhookSecret(), ); } catch { res.status(400).send("invalid signature"); return; } switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": await fulfillCheckout(event.data.object.id); break; } return "ok"; }
index.ts
app.route({ method: "post", path: "/stripe/webhook", handler: stripeWebhook, });

A handler that throws returns a 500 and Stripe retries the event later, so let errors throw. fulfillCheckout rereads the session from Stripe, so the event body is used only for its id. Subscription events are handled in payments/subscriptions.

stripe trigger checkout.session.completed sends a synthetic session with no client_reference_id, which fulfillCheckout ignores. Test with a real sandbox checkout.

Related

  • payments/checkout: fulfillCheckout.
  • payments/checkout: startCheckout, which calls ensureWebhook().
  • payments/live: what happens on the first production deploy.