Exactly-once webhook processing
A Stripe-style webhook handler where the event ID is the workflow ID — duplicate deliveries dedupe, partial failures resume, the customer gets charged once.
A webhook receives a payment event, kicks off a durable workflow keyed by the provider's event ID, and acknowledges immediately. Duplicate deliveries (network retries, slow ACKs) reconnect to the in-flight execution rather than re-charging the card. A crash mid-processing resumes from the next unfinished step.
TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x. Rust example repo is forthcoming.
Stripe-style webhook with validate → charge → receipt → ledger, exactly once.
FastAPI webhook with the same validate → charge → receipt → ledger pipeline, exactly once.
The problem#
Webhook providers retry on any timeout, slow ACK, or 5xx response — that's the whole point. Your handler has to be idempotent, or duplicate deliveries double-charge the customer. The conventional fix is a "processed events" table you check before doing work, paired with a worker that polls a queue, paired with retry logic for partial failures. Three moving parts, and any one of them not being idempotent breaks the whole shape.
Resonate's solution#
The provider's event ID is the workflow ID. Resonate's promise dedup means a duplicate delivery with the same event ID reconnects to the in-flight (or completed) execution rather than starting a second one. The downstream steps — charge card, send receipt, update ledger — each checkpoint, so a crash between any two resumes from the next step rather than re-running the whole pipeline.
The handler ACKs the webhook in well under five seconds because it just dispatches the workflow and returns; the processing happens durably in the background.
Code walkthrough#
The webhook endpoint#
Receive, dispatch, ACK. The whole point is that the workflow runs in the background — Stripe (or any provider) gets its fast 200 OK regardless of how long the actual processing takes.
app.post("/webhook", (req, res) => {
const event = req.body as WebhookEvent;
// The event_id IS the workflow ID.
// Stripe retries with the same event_id reconnect to the in-flight execution.
resonate
.run(`webhook/${event.event_id}`, processPayment, event, simulateCrash)
.catch(console.error);
// Fast ACK — Stripe needs a 200 within 5 seconds.
res.status(200).json({ received: true });
});@app.post("/webhook")
async def webhook(event: WebhookEvent):
# The event_id IS the workflow ID.
# Stripe retries with the same event_id reconnect to the in-flight execution.
process_payment.run(f"webhook/{event['event_id']}", event, simulate_crash)
# Fast ACK — Stripe needs a 200 within 5 seconds.
return {"received": True}A separate GET /status/:event_id lets you poll the workflow's result if you need it.
The exactly-once workflow#
Every step is a ctx.run checkpoint. If the worker dies after charge_card but before update_ledger, restart resumes at send_receipt — the card is not charged again.
export function* processPayment(
ctx: Context,
event: WebhookEvent,
): Generator<any, PaymentResult, any> {
// Validate signature and event structure.
yield* ctx.run(validateEvent, event);
// Charge the card. Checkpointed — duplicates skip this step.
const chargeId = yield* ctx.run(chargeCard, event);
// Send the receipt.
yield* ctx.run(sendReceipt, event, chargeId);
// Update the accounting ledger.
return yield* ctx.run(updateLedger, event, chargeId);
}@resonate.register
def process_payment(ctx: Context, event: WebhookEvent, simulate_crash: bool):
# Validate signature and event structure.
yield ctx.run(validate_event, event)
# Charge the card. Checkpointed — duplicates skip this step.
charge_id = yield ctx.run(charge_card, event, simulate_crash)
# Send the receipt.
yield ctx.run(send_receipt, event, charge_id)
# Update the accounting ledger.
return (yield ctx.run(update_ledger, event, charge_id))Both repos include a --crash flag that simulates charge_card failing on the first attempt — re-running the workflow shows the charge happening exactly once even across retries.
Run it locally#
git clone https://github.com/resonatehq-examples/example-webhook-handler-ts
cd example-webhook-handler-ts
bun installbrew install resonatehq/tap/resonate
resonate devbun run src/index.tsgit clone https://github.com/resonatehq-examples/example-webhook-handler-py
cd example-webhook-handler-py
uv syncThe Python repo runs in embedded mode (Resonate.local()) so no separate server is needed for the demo.
uv run python main.pySend a webhook:
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-d '{"event_id": "evt_001", "type": "payment.succeeded", "amount": 4999, "currency": "USD"}'Send the same webhook again. Watch the logs — the second delivery doesn't re-charge.
Try the crash story#
Run with the crash flag — chargeCard / charge_card throws on its first attempt, the workflow retries, and the second attempt succeeds. The card is charged exactly once.
bun run src/index.ts -- --crashuv run python main.py --crashRelated#
- Money transfer — same idempotency primitive, applied to a saga.
- Async HTTP API endpoints — fire-and-forget HTTP with the same workflow-ID-as-dedup-key shape.