The Webhook
elements man payments/webhook Read as markdownStripe 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/webhookprints a signing secret. It goes inSTRIPE_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.
Terminalelements create migration "add stripe webhooks" -tables=stripeWebhooks
app/migrations/20260509120000-add-stripe-webhooks.migration.sqlcreate 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.tsimport { 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.tsimport { 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.tsapp.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 callsensureWebhook().payments/live: what happens on the first production deploy.