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 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.