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 and stores the secret it gets back. Stripe returns an endpoint's secret only when it is created.
elements create migration "add stripe settings" -tables=stripeSettings
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
);
// 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<boolean> {
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.
// 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/live: what happens on the first production deploy.