# 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 and stores the secret it gets back. Stripe returns an endpoint's secret only when it is created. ```bash elements create migration "add stripe settings" -tables=stripeSettings ``` ```sql create table stripeSettings ( id integer primary key default 1 check (id = 1), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), webhookEndpointId text not null, webhookSecret text not null, webhookUrl text not null ); ``` ```ts // app/shared/stripe-webhook.ts import { 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; /** Makes sure Stripe has an endpoint for this app's url. Production only. */ export async function ensureWebhook(): Promise { let url = `${getAppUrl()}/stripe/webhook`; let saved = sql<{ webhookEndpointId: string; webhookUrl: string }>( `select webhookEndpointId, webhookUrl from stripeSettings`, ).first(); if (saved && saved.webhookUrl === url) { return true; } if (saved) { await stripe().webhookEndpoints.del(saved.webhookEndpointId); } let endpoint = await stripe().webhookEndpoints.create({ url, enabled_events: [...EVENTS], description: "Registered by the app", }); sql(` insert into stripeSettings (webhookEndpointId, webhookSecret, webhookUrl) values (${endpoint.id}, ${endpoint.secret!}, ${url}) on conflict (id) do update set webhookEndpointId = excluded.webhookEndpointId, webhookSecret = excluded.webhookSecret, webhookUrl = excluded.webhookUrl `); return true; } export function webhookSecret(): string { if (getEnv() === "development") { return config.stripe.webhookSecret; } return sql<{ webhookSecret: string }>(`select webhookSecret from stripeSettings`).first()?.webhookSecret ?? ""; } ``` The setup page calls `ensureWebhook()` in production, so opening `/admin/payments` once after the first deploy registers the endpoint. A domain change registers a new one and deletes the old. Never call it in development: Stripe cannot reach localhost. ## 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/live`: what happens on the first production deploy.