# The Webhook 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:/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. ```bash Terminal elements create migration "add stripe webhooks" -tables=stripeWebhooks ``` ```sql 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. ```ts // 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 { 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. ```ts // 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"; } ``` ```ts // 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.