Skip to main content

Receiving Stripe webhooks

This guide wires Stripe's invoice.paid event to a flow action and tests it locally with the Stripe CLI. By the end you'll have an endpoint Stripe can POST to, a verified signature, and an action that records the payment.

What we're building: when Stripe marks an invoice paid, our billing flow runs a recordPayment action. Stripe signs every delivery; we verify it before doing anything. The action runs asynchronously, so Stripe gets its 2xx ack fast.

1. The flow declares routing​

The flow says which Stripe event runs which action. Nothing else — no secret, no signature code. Those imports come from @flow-state-dev/core.

flows/billing.ts
import { defineFlow, defineWebhookBinding, sequencer, handler } from "@flow-state-dev/core";
import { z } from "zod";

interface StripeEvent {
id: string;
type: string;
data: { object: { id: string; customer: string; amount_paid: number } };
}

const recordPayment = handler({
name: "record-payment",
inputSchema: z.object({ invoiceId: z.string(), amountCents: z.number() }),
execute: async ({ invoiceId, amountCents }) => {
// Idempotent write keyed on invoiceId — Stripe delivers at-least-once.
await ledger.recordPayment(invoiceId, amountCents);
},
});

const recordPaymentPipeline = sequencer({ name: "record-payment-pipeline" })
.tap(recordPayment);

export const billingFlow = defineFlow({
kind: "billing",
actions: {}, // no caller-facing actions: webhooks are the only way in
authentication: { defaultUserId: "system", requireUser: false },
webhooks: {
stripe: {
on: {
"invoice.paid": defineWebhookBinding<StripeEvent>({
block: recordPaymentPipeline,
input: (e) => ({
invoiceId: e.payload.data.object.id,
amountCents: e.payload.data.object.amount_paid,
}),
sessionId: (e) => `customer-${e.payload.data.object.customer}`,
}),
},
},
},
});

requireUser: false plus defaultUserId: "system" is the standard webhook auth shape: no end user, run as a system principal. Deriving sessionId from the customer id keeps each customer's payments in one session; drop it if you don't need continuity.

2. The host verifies and parses​

Verification and the Stripe-specific mechanics live on the adapter mount, keyed by the stripe provider name. Stripe carries the event type in the body (payload.type) and a stable delivery id (payload.id).

lib/flowstate.ts
import { createFlowState, inMemoryStores } from "@flow-state-dev/engine";
import {
createWebhookTransportAdapter,
stripeWebhookVerifier,
} from "@flow-state-dev/engine";
import { billingFlow } from "@/flows/billing";

export const flowstate = createFlowState({
flows: { billing: billingFlow() },
stores: { default: { primary: inMemoryStores() } },
adapters: [
createWebhookTransportAdapter({
providers: {
stripe: {
verify: stripeWebhookVerifier(() => process.env.STRIPE_WEBHOOK_SECRET!),
eventType: (payload) => (payload as { type: string }).type,
deliveryId: (payload) => (payload as { id: string }).id,
},
},
}),
],
});
app/api/flows/[...path]/route.ts
import { flowstate } from "@/lib/flowstate";
import { createVercelNextHandler } from "@flow-state-dev/vercel/next";

export const { GET, POST, PATCH, DELETE } = createVercelNextHandler(flowstate);

The endpoint is now live at POST /api/flows/billing/webhooks/stripe. stripeWebhookVerifier reads Stripe-Signature, signs <timestamp>.<rawBody>, and rejects deliveries older than five minutes. Wiring deliveryId puts Stripe's event id on metadata.webhook.deliveryId so your action can dedup.

3. Test it locally​

The Stripe CLI forwards live events to your local server and signs them with a real secret, so you exercise the verification path end to end.

# Forward Stripe events to your local endpoint. Prints a signing secret.
stripe listen --forward-to localhost:3000/api/flows/billing/webhooks/stripe

stripe listen prints a whsec_... secret. Put it in your environment so the verifier matches:

export STRIPE_WEBHOOK_SECRET=whsec_...

Then trigger a invoice.paid event from another terminal:

stripe trigger invoice.payment_succeeded

You should see the CLI report a 200/202 back from your endpoint, and your recordPayment action run in the server logs (or the DevTool request list, with a webhook badge). A 401 means the secret doesn't match what stripe listen printed; a 404 means the provider name or flow kind in the URL is off.

What you have​

  • A verified Stripe endpoint at POST /api/flows/billing/webhooks/stripe.
  • invoice.paid deliveries routed to recordPayment, keyed per customer.
  • Fast 2xx acks — the action runs after the response, well inside Stripe's retry budget.

For the full surface — route for dynamic mapping, the when predicate, idempotency details — see the webhook receivers reference.