Webhook providers retry on slow ACKs and network timeouts, so a payment webhook can arrive twice and double-charge the customer unless the handler deduplicates. Resonate's shape of solution is to use the provider's event_id as the durable promise id: resonate.run("webhook/{event_id}", ...) returns the cached result of the existing execution if it has been seen, and creates a new one if not. This example demonstrates that with an Express receiver, a four-step generator workflow (validate, charge, receipt, ledger), and a --crash mode that fails the charge step on its first attempt to show step-level retry without re-running the earlier steps.
The shape of the solution
export function* processPayment(
ctx: Context,
event: WebhookEvent,
simulateCrash: boolean,
): Generator<any, PaymentResult, any> {
// Step 1: Validate signature and event structure
yield* ctx.run(validateEvent, event);
// Step 2: Charge the card — checkpointed.
// If this crashes and retries, we call the payment processor exactly once.
// If a duplicate webhook arrives with the same event_id, this step is
// returned from cache — the processor is never called again.
const chargeId = yield* ctx.run(chargeCard, event, simulateCrash);
// Step 3: Send receipt
yield* ctx.run(sendReceipt, event, chargeId);
// Step 4: Update accounting ledger
const result = yield* ctx.run(updateLedger, event, chargeId);
return result;
}
// from example-webhook-handler-ts/src/workflow.ts:29-50The orchestrator is a TypeScript generator. Each yield* ctx.run(...) is a durable checkpoint: the SDK records the invocation, executes the leaf, records the result, and only then advances the generator. The dedup is not in this function — it is in the entry point, where the event_id from the request body becomes the promise id:
const resonate = new Resonate();
resonate.register(processPayment);
// ...
resonate
.run(`webhook/${event.event_id}`, processPayment, event, simulateCrash)
.catch(console.error);
// from example-webhook-handler-ts/src/index.ts:10-11, 36-38The durable primitives in play
new Resonate()— constructs a Resonate client in embedded mode; no server process required (src/index.ts:10; README:34).resonate.register(processPayment)— registers the generator as an invocable workflow under its function name; required beforerunorbeginRuncan dispatch it (src/index.ts:11; SDK enforces this atresonate.ts:314-320ofresonate-sdk-tsv0.10.0).resonate.run(id, func, ...args)— awaits the durable execution to completion. If a durable promise withidalready exists, the SDK returns its cached result instead of starting a second execution. This is the deduplication mechanism. InternallyrunisbeginRun(...).result()(resonate.ts:299-301ofresonate-sdk-tsv0.10.0). Used atsrc/index.ts:37.ctx.run(fn, ...args)— durable local function call. The SDK checkpoints at the invocation and at the result; on replay, a completedctx.runreturns its stored value instead of re-running. Aliased tolfcatcontext.ts:207-208ofresonate-sdk-tsv0.10.0. Used atsrc/workflow.ts:35, 41, 44, 47.resonate.get(id)— fetches a handle for an existing execution. The/status/:event_idroute polls this and callshandle.done()/handle.result()(src/index.ts:47-55).
What the SDK handles vs. what you write
The SDK handles: assigning a durable promise to each run and to each ctx.run leaf; detecting that an incoming run id matches an existing promise and returning the existing handle rather than re-executing; persisting each step's result so the generator resumes from the last completed checkpoint after a crash; retrying a failed step according to the configured retry policy (default exponential backoff per the SDK) without re-running earlier completed steps.
You write: the generator that yields the steps in their business order; the four leaf functions (validateEvent, chargeCard, sendReceipt, updateLedger); the choice to key the promise id off event_id rather than some other field; the Express route that extracts event_id, fires resonate.run without awaiting it, and returns 200 immediately so the provider's 5-second ACK window is not blocked by the workflow. The signature verification stub at src/handlers.ts:39-46 is also yours — Resonate does not authenticate the upstream caller.
Failure modes covered
- The same
event_idarrives twice. The secondresonate.run("webhook/{event_id}", ...)finds the existing promise and returns its cached result. None ofvalidateEvent,chargeCard,sendReceipt, orupdateLedgerruns a second time. README:46-82 demonstrates this — the second POST logs only the receiving line atsrc/index.ts:32and produces no further per-step logs. The status route returns the samecharge_idfrom the first run. - The payment processor times out on the first attempt.
chargeCardthrowsError("Payment processor timeout — will retry")(src/handlers.ts:65-69) whensimulateCrashis true and the per-event attempt counter is 1. The SDK retries thectx.run(chargeCard, ...)step.validateEventis not re-run because its result is already checkpointed; the demo's per-eventchargeAttemptsmap (src/handlers.ts:33, 57-58) increments only insidechargeCard. On attempt 2 the function returns a realchargeIdand the workflow proceeds. - The process crashes after
chargeCardsucceeds but beforesendReceiptruns. On restart,processPaymentis replayed.validateEventandchargeCardreturn their checkpointed results without re-executing, so the customer is not charged a second time. Execution resumes at thesendReceiptstep. (README:16-17 states this guarantee; the mechanism is thectx.runcheckpoint atsrc/workflow.ts:41.) - Express returns 200 before processing finishes. The route does not
awaitthe workflow — it firesresonate.run(...).catch(console.error)atsrc/index.ts:36-38and responds withres.status(200).json({ received: true })atsrc/index.ts:41. The durable promise is created synchronously insiderun(it resolves once the workflow finishes), so even if the HTTP handler returns immediately, the workflow continues durably and/status/:event_idcan be polled to retrieve the eventual result.
When to reach for this pattern
- If you are receiving webhooks from a provider that retries on timeout (Stripe, GitHub, Shopify, Twilio) and you need exactly-once business effects without maintaining a dedup table or distributed lock.
- If a request handler must ACK within seconds but the work behind it spans multiple side-effecting steps that you want resumed across crashes.
- If you have a natural, provider-supplied unique id per event (
event_id,delivery_id, message id) that can serve as the durable promise id. - If you want step-level retry on a single flaky dependency without rerunning the cheaper upstream steps that already succeeded.
- If you want to start in-process (embedded
new Resonate()) for development and later move to a server-backed deployment without changing the workflow code.
Sources
- Example repo: https://github.com/resonatehq-examples/example-webhook-handler-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
- Pinned SDK version:
@resonatehq/sdk^0.10.0(package.json:11) - Workflow generator:
src/workflow.ts:29-50 - Step handlers (
validateEvent,chargeCard,sendReceipt,updateLedger):src/handlers.ts:39-110 - Express receiver +
resonate.rundedup:src/index.ts:24-42 - Status route via
resonate.get:src/index.ts:45-60 resonate.run/beginRunsemantics at v0.10.0:src/resonate.ts:296-355(SDK repo, tagv0.10.0)ctx.run(alias oflfc) at v0.10.0:src/context.ts:207-208(SDK repo, tagv0.10.0)- Resonate docs: https://docs.resonatehq.io
