Handle Incoming Webhooks
Accept webhooks from Stripe, GitHub, or Shopify, process each event once, and keep events for the same customer in order.
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
/webhooksroute that checks the provider’s signature and rejects anything that fails. - A
customerEventsworkflow 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
npm add @rivet-dev/workflows rivetkit hono @hono/node-server
Get a signing secret
Stripe signs every webhook with a secret that belongs to the endpoint. In the Stripe Dashboard, add an endpoint for https://<your-server>/webhooks and copy its signing secret. To test locally, the Stripe CLI prints one when you run stripe listen. Then set:
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:
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-Signatureheader with a timestamp and an HMAC-SHA256 of<timestamp>.<raw body>. The check recomputes it with your secret and compares the two withtimingSafeEqual, 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
stripepackage does the same check withstripe.webhooks.constructEvent. Use it if you already depend on it.
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:
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
sendhas no idempotency key, so the workflow dedupes by event id. Stripe gives every event a uniqueidand 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
statestays small.
Apply events in order
Once an event is known to be new, the workflow writes it to your database:
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,
}),
},
});
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,
tryStepretries 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
failedinstead 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.tskeeps rows in memory so the example runs on its own. ReplaceapplyEventwith writes to your database.
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.
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:
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 nocustomerfield, so the object’s own id, which is the customer id, is used instead. sendresolves once the event is stored in the workflow’s queue. The route answers200right 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
400and never reaches the workflow.
Try it
Start the server:
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:
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());
STRIPE_WEBHOOK_SECRET=whsec_test npx tsx send-test-event.ts
Both copies are accepted, but the event is applied once:
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:
stripe listen --forward-to localhost:3000/webhooks
stripe trigger customer.subscription.updated
To receive webhooks from a provider before you deploy, run ngrok http 3000 and use the ngrok URL as https://<your-server>.
Next steps
- Reliable Webhook Delivery: send your own events to customer endpoints with retries.
- Human-in-the-Loop Approvals: ask a person before acting on an event, such as a refund request.
- Failure & Recovery: get notified when an event lands in
failed.
Related docs
- Queues & Messaging: the queue-driven worker this guide builds on.
- Steps: how
tryStepreturns failures as data. - Timers & Concurrency: retries, backoff, and why steps should be idempotent.
- Reference: recommendations for replay-safe workflows.