# Handle Incoming Webhooks

Stripe, GitHub, or Shopify posts each event to the /webhooks route of your HTTP server. The route verifies the signature and calls send on the workflow keyed by the customer or object the event is about, then answers 200 right away. The workflow records each event id and skips ids it has already seen, then handles events one at a time in the order they arrived, writing each one to your database in the apply-event step. The numbers show the order of the calls. There is one workflow per customer.

Webhook providers send each event at least once. They resend it when your server is slow to answer or answers with an error, and they do not promise to send events in order. This guide puts a workflow in front of your database so each event is applied once, and events about the same customer are applied one after another.

What we'll build:

- A `/webhooks` route that checks the provider's signature and rejects anything that fails.
- A `customerEvents` workflow per customer, keyed by the customer id in the event.
- A record of event ids in the workflow, so a redelivered event is skipped instead of applied twice.
- An answer sent as soon as the event is queued, well inside the provider's timeout.
- Steps that apply each event to your database in arrival order and retry with backoff when your database is down.

The example uses Stripe. The same shape works for GitHub, Shopify, or any provider that signs its requests.

### Install

```sh
npm add @rivet-dev/workflows rivetkit@next hono @hono/node-server
```

### Get a signing secret

Stripe signs every webhook with a secret that belongs to the endpoint. In the <a href="https://dashboard.stripe.com/webhooks" target="_blank" rel="noopener noreferrer">Stripe Dashboard</a>, add an endpoint for `https://<your-server>/webhooks` and copy its signing secret. To test locally, the <a href="https://docs.stripe.com/stripe-cli" target="_blank" rel="noopener noreferrer">Stripe CLI</a> prints one when you run `stripe listen`. Then set:

```sh
STRIPE_WEBHOOK_SECRET=whsec_...
```

### Verify the signature

Anyone can post to a public URL, so the route checks that each request came from Stripe before it does anything else:

stripe.ts:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

// The parts of a Stripe event this guide reads.
export type StripeEvent = {
	id: string;
	type: string;
	created: number;
	data: {
		object: {
			id: string;
			customer?: string | null;
			[field: string]: unknown;
		};
	};
};

// Reject requests signed longer ago than this, so a captured request
// cannot be replayed later.
const TOLERANCE_SECONDS = 5 * 60;

// Checks the `Stripe-Signature` header, which looks like `t=1700000000,v1=<hex>`.
export function verifyStripeSignature(
	rawBody: string,
	header: string | undefined,
	secret: string,
): boolean {
	if (!header) return false;
	const pairs = header.split(",").map((part) => part.split("=", 2));
	const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]);
	const signatures = pairs
		.filter(([key]) => key === "v1")
		.map(([, value]) => value);
	if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
	if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;

	// Stripe signs `<timestamp>.<raw body>` with HMAC-SHA256, keyed by the
	// endpoint's signing secret.
	const expected = createHmac("sha256", secret)
		.update(`${timestamp}.${rawBody}`)
		.digest();
	return signatures.some((signature) => {
		const given = Buffer.from(signature, "hex");
		return given.length === expected.length && timingSafeEqual(given, expected);
	});
}

// Signs a payload the way Stripe does, for `send-test-event.ts`.
export function signStripePayload(rawBody: string, secret: string): string {
	const timestamp = Math.floor(Date.now() / 1000);
	const signature = createHmac("sha256", secret)
		.update(`${timestamp}.${rawBody}`)
		.digest("hex");
	return `t=${timestamp},v1=${signature}`;
}
```

- Stripe sends a `Stripe-Signature` header with a timestamp and an HMAC-SHA256 of `<timestamp>.<raw body>`. The check recomputes it with your secret and compares the two with `timingSafeEqual`, which takes the same time whether or not they match.
- Requests signed more than 5 minutes ago are rejected, so someone who captures a request cannot send it again later.
- The official `stripe` package does the same check with `stripe.webhooks.constructEvent`. Use it if you already depend on it.

> **NOTE:** Other providers sign differently. GitHub sends `X-Hub-Signature-256`, a hex HMAC-SHA256 of the raw body. Shopify sends `X-Shopify-Hmac-Sha256`, a base64 HMAC-SHA256 of the raw body. Swap in a check for your provider. The rest of this guide stays the same.

### Skip events you have already seen

Each customer gets a `customerEvents` workflow, keyed by their Stripe customer id. It takes events off its queue one at a time and records each event id before it handles the event:

customer-events.ts:

```ts
import { queue, workflow } from "@rivet-dev/workflows";
import { applyEvent } from "./db";
import type { StripeEvent } from "./stripe";

// How long to remember an event id. Stripe retries a failed delivery for
// up to three days.
const SEEN_FOR_MS = 7 * 24 * 60 * 60 * 1000;

// One workflow per customer, keyed by the Stripe customer id.
export const customerEvents = workflow({
	state: {
		// Event id to the time it was first received.
		seen: {} as Record<string, number>,
		applied: 0,
		duplicates: 0,
		failed: [] as { eventId: string; type: string; error: string }[],
	},
	queues: {
		events: queue<StripeEvent>(),
	},
	run: async (ctx) => {
		await ctx.loop("event-loop", async (loopCtx) => {
			// Events come off the queue one at a time, in the order they arrived.
			const { body: event } = await loopCtx.queue.next("wait-event", {
				names: ["events"],
			});

			const isNew = await loopCtx.step("record-event-id", async (step) => {
				const now = Date.now();
				for (const [id, seenAt] of Object.entries(step.state.seen)) {
					if (now - seenAt > SEEN_FOR_MS) delete step.state.seen[id];
				}
				if (step.state.seen[event.id] !== undefined) {
					step.state.duplicates += 1;
					return false;
				}
				step.state.seen[event.id] = now;
				return true;
			});
			if (!isNew) return;

			// Retries with backoff while your database is down: 1s, 2s, 4s, and so
			// on, up to 1 minute apart.
			const result = await loopCtx.tryStep({
				name: "apply-event",
				maxRetries: 10,
				retryBackoffBase: 500,
				retryBackoffMax: 60_000,
				run: async () => await applyEvent(event),
			});

			await loopCtx.step("record-result", async (step) => {
				if (result.ok) {
					step.state.applied += 1;
					return;
				}
				// Park the event so it does not block this customer's later events.
				step.state.failed.push({
					eventId: event.id,
					type: event.type,
					error: result.failure.error.message,
				});
			});
		});
	},
	actions: {
		getStatus: (c) => ({
			applied: c.state.applied,
			duplicates: c.state.duplicates,
			failed: c.state.failed,
		}),
	},
});
```

- Queue `send` has no idempotency key, so the workflow dedupes by event id. Stripe gives every event a unique `id` and keeps it the same on every retry.
- The check and the write happen in one step, and the workflow handles one event at a time. A redelivery that arrives while the first copy is still queued is still caught.
- The step's result is saved in the workflow's history. If the Rivet Actor restarts, it replays the saved result instead of checking again, so an event is never counted as new twice.
- Ids are kept for 7 days, longer than Stripe's 3 days of retries, then dropped so `state` stays small.

### Apply events in order

Once an event is known to be new, the workflow writes it to your database:

customer-events.ts:

```ts
import { queue, workflow } from "@rivet-dev/workflows";
import { applyEvent } from "./db";
import type { StripeEvent } from "./stripe";

// How long to remember an event id. Stripe retries a failed delivery for
// up to three days.
const SEEN_FOR_MS = 7 * 24 * 60 * 60 * 1000;

// One workflow per customer, keyed by the Stripe customer id.
export const customerEvents = workflow({
	state: {
		// Event id to the time it was first received.
		seen: {} as Record<string, number>,
		applied: 0,
		duplicates: 0,
		failed: [] as { eventId: string; type: string; error: string }[],
	},
	queues: {
		events: queue<StripeEvent>(),
	},
	run: async (ctx) => {
		await ctx.loop("event-loop", async (loopCtx) => {
			// Events come off the queue one at a time, in the order they arrived.
			const { body: event } = await loopCtx.queue.next("wait-event", {
				names: ["events"],
			});

			const isNew = await loopCtx.step("record-event-id", async (step) => {
				const now = Date.now();
				for (const [id, seenAt] of Object.entries(step.state.seen)) {
					if (now - seenAt > SEEN_FOR_MS) delete step.state.seen[id];
				}
				if (step.state.seen[event.id] !== undefined) {
					step.state.duplicates += 1;
					return false;
				}
				step.state.seen[event.id] = now;
				return true;
			});
			if (!isNew) return;

			// Retries with backoff while your database is down: 1s, 2s, 4s, and so
			// on, up to 1 minute apart.
			const result = await loopCtx.tryStep({
				name: "apply-event",
				maxRetries: 10,
				retryBackoffBase: 500,
				retryBackoffMax: 60_000,
				run: async () => await applyEvent(event),
			});

			await loopCtx.step("record-result", async (step) => {
				if (result.ok) {
					step.state.applied += 1;
					return;
				}
				// Park the event so it does not block this customer's later events.
				step.state.failed.push({
					eventId: event.id,
					type: event.type,
					error: result.failure.error.message,
				});
			});
		});
	},
	actions: {
		getStatus: (c) => ({
			applied: c.state.applied,
			duplicates: c.state.duplicates,
			failed: c.state.failed,
		}),
	},
});
```

db.ts:

```ts
import type { StripeEvent } from "./stripe";

// A stand-in for your database so the example runs on its own.
// Replace it with writes to your own database.
const subscriptions = new Map<
	string,
	{ customerId: string; status: string; updatedBy: string }
>();

export async function applyEvent(event: StripeEvent): Promise<void> {
	const object = event.data.object;
	switch (event.type) {
		case "customer.subscription.created":
		case "customer.subscription.updated":
		case "customer.subscription.deleted":
			// An upsert keyed by the subscription id. Writing the same event twice
			// leaves the same row.
			subscriptions.set(object.id, {
				customerId: String(object.customer),
				status: String(object.status),
				updatedBy: event.id,
			});
			console.log(
				`applied ${event.id} (${event.type}): ${object.id} is ${object.status}`,
			);
			return;
		default:
			console.log(`ignored ${event.id} (${event.type})`);
	}
}
```

- Events for one customer are applied one after another, in the order they reached your server. Events for different customers run in parallel, because each customer has its own workflow.
- If your database is down, `tryStep` retries with backoff, starting at 1 second and doubling up to 1 minute. The waits are durable, so the Rivet Actor can sleep or restart in between.
- If every retry fails, the event is added to `failed` instead of stopping the workflow. That list is your dead letter queue: later events keep flowing, and you can replay the failed ones once the cause is fixed.
- `db.ts` keeps rows in memory so the example runs on its own. Replace `applyEvent` with writes to your database.

> **WARNING:** Make the write idempotent, such as an upsert keyed by the object id. If the process dies after the write commits but before the step is saved, the step runs again on restart.

> **NOTE:** Stripe does not promise to deliver events in the order they happened. When order matters, compare the event's `created` time with the last one you applied, or fetch the latest version of the object from Stripe before writing it.

### Receive webhooks

The HTTP server has one route. It verifies the request, hands the event to the right customer's workflow, and answers:

server.ts:

```ts
import { serve } from "@hono/node-server";
import { setup } from "@rivet-dev/workflows";
import { Hono } from "hono";
import { createClient } from "rivetkit/client";
import { customerEvents } from "./customer-events";
import { type StripeEvent, verifyStripeSignature } from "./stripe";

const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
	throw new Error("Set STRIPE_WEBHOOK_SECRET to your signing secret.");
}

export const registry = setup({ use: { customerEvents } });
const client = createClient<typeof registry>();

registry.start();

const app = new Hono();

app.post("/webhooks", async (c) => {
	// Check the signature against the raw body. Parsed and re-encoded JSON
	// would not match the bytes the provider signed.
	const rawBody = await c.req.text();
	const signature = c.req.header("stripe-signature");
	if (!verifyStripeSignature(rawBody, signature, webhookSecret)) {
		return c.text("invalid signature", 400);
	}

	const event = JSON.parse(rawBody) as StripeEvent;
	const customerId = event.data.object.customer ?? event.data.object.id;

	// `send` returns once the event is stored in the customer's queue,
	// before the workflow handles it.
	await client.customerEvents.getOrCreate([customerId]).send("events", event);
	return c.json({ received: true });
});

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
```

- The route reads the raw body with `c.req.text()`. The signature covers the exact bytes Stripe sent, so check it before parsing the JSON.
- Events are keyed by `customer`. Events about a customer object have no `customer` field, so the object's own id, which is the customer id, is used instead.
- `send` resolves once the event is stored in the workflow's queue. The route answers `200` right away instead of waiting for your database, so it stays well inside the few seconds providers wait before they give up and retry.
- A request with a bad signature gets a `400` and never reaches the workflow.

### Try it

Start the server:

```sh
STRIPE_WEBHOOK_SECRET=whsec_test npx tsx server.ts
```

In another terminal, post a signed test event, then the same event again, then one with a forged signature:

send-test-event.ts:

```ts
import { createClient } from "rivetkit/client";
import type { registry } from "./server";
import { type StripeEvent, signStripePayload } from "./stripe";

const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
	throw new Error("Set STRIPE_WEBHOOK_SECRET to the server's value.");
}
const url = process.env.WEBHOOK_URL ?? "http://localhost:3000/webhooks";

const event: StripeEvent = {
	id: `evt_test_${Date.now()}`,
	type: "customer.subscription.updated",
	created: Math.floor(Date.now() / 1000),
	data: { object: { id: "sub_123", customer: "cus_123", status: "active" } },
};
const body = JSON.stringify(event);

// Post the same signed event twice, the way a provider redelivers an event
// when it does not get your answer.
for (const label of ["delivery", "redelivery"]) {
	const res = await fetch(url, {
		method: "POST",
		headers: {
			"content-type": "application/json",
			"stripe-signature": signStripePayload(body, secret),
		},
		body,
	});
	console.log(`${label}: ${res.status} ${await res.text()}`);
}

// A request with a bad signature is rejected before it reaches the workflow.
const forged = await fetch(url, {
	method: "POST",
	headers: {
		"content-type": "application/json",
		"stripe-signature": "t=0,v1=00",
	},
	body,
});
console.log(`forged: ${forged.status} ${await forged.text()}`);

// Give the workflow a moment to drain its queue, then read its counts.
await new Promise((resolve) => setTimeout(resolve, 1000));
const client = createClient<typeof registry>();
console.log(await client.customerEvents.getOrCreate(["cus_123"]).getStatus());
```

```sh
STRIPE_WEBHOOK_SECRET=whsec_test npx tsx send-test-event.ts
```

Both copies are accepted, but the event is applied once:

```text
delivery: 200 {"received":true}
redelivery: 200 {"received":true}
forged: 400 invalid signature
{ applied: 1, duplicates: 1, failed: [] }
```

The server logs a single `applied evt_test_... (customer.subscription.updated): sub_123 is active` line.

To try real Stripe events, forward them to the server with the Stripe CLI and start the server with the secret it prints:

```sh
stripe listen --forward-to localhost:3000/webhooks
stripe trigger customer.subscription.updated
```

> **NOTE:** To receive webhooks from a provider before you deploy, run `ngrok http 3000` and use the ngrok URL as `https:// `.

## Next steps

- [Reliable Webhook Delivery](/guides/webhook-delivery): send your own events to customer endpoints with retries.
- [Human-in-the-Loop Approvals](/guides/human-in-the-loop): ask a person before acting on an event, such as a refund request.
- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): get notified when an event lands in `failed`.

## Related docs

- [Queues & Messaging](/workflows/docs/queues#queue-driven-worker): the queue-driven worker this guide builds on.
- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data.
- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): retries, backoff, and why steps should be idempotent.
- [Reference](/workflows/docs/reference#recommendations): recommendations for replay-safe workflows.
