Durable webhook deduplication keyed by event_id in TypeScript

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-50

The 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-38

The 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 before run or beginRun can dispatch it (src/index.ts:11; SDK enforces this at resonate.ts:314-320 of resonate-sdk-ts v0.10.0).

resonate.run(id, func, ...args) — awaits the durable execution to completion. If a durable promise with id already exists, the SDK returns its cached result instead of starting a second execution. This is the deduplication mechanism. Internally run is beginRun(...).result() (resonate.ts:299-301 of resonate-sdk-ts v0.10.0). Used at src/index.ts:37.

ctx.run(fn, ...args) — durable local function call. The SDK checkpoints at the invocation and at the result; on replay, a completed ctx.run returns its stored value instead of re-running. Aliased to lfc at context.ts:207-208 of resonate-sdk-ts v0.10.0. Used at src/workflow.ts:35, 41, 44, 47.

resonate.get(id) — fetches a handle for an existing execution. The /status/:event_id route polls this and calls handle.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_id arrives twice. The second resonate.run("webhook/{event_id}", ...) finds the existing promise and returns its cached result. None of validateEvent, chargeCard, sendReceipt, or updateLedger runs a second time. README:46-82 demonstrates this — the second POST logs only the receiving line at src/index.ts:32 and produces no further per-step logs. The status route returns the same charge_id from the first run.

The payment processor times out on the first attempt. chargeCard throws Error("Payment processor timeout — will retry") (src/handlers.ts:65-69) when simulateCrash is true and the per-event attempt counter is 1. The SDK retries the ctx.run(chargeCard, ...) step. validateEvent is not re-run because its result is already checkpointed; the demo's per-event chargeAttempts map (src/handlers.ts:33, 57-58) increments only inside chargeCard. On attempt 2 the function returns a real chargeId and the workflow proceeds.

The process crashes after chargeCard succeeds but before sendReceipt runs. On restart, processPayment is replayed. validateEvent and chargeCard return their checkpointed results without re-executing, so the customer is not charged a second time. Execution resumes at the sendReceipt step. (README:16-17 states this guarantee; the mechanism is the ctx.run checkpoint at src/workflow.ts:41.)

Express returns 200 before processing finishes. The route does not await the workflow — it fires resonate.run(...).catch(console.error) at src/index.ts:36-38 and responds with res.status(200).json({ received: true }) at src/index.ts:41. The durable promise is created synchronously inside run (it resolves once the workflow finishes), so even if the HTTP handler returns immediately, the workflow continues durably and /status/:event_id can 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.run dedup: src/index.ts:24-42

Status route via resonate.get: src/index.ts:45-60

resonate.run / beginRun semantics at v0.10.0: src/resonate.ts:296-355 (SDK repo, tag v0.10.0)

ctx.run (alias of lfc) at v0.10.0: src/context.ts:207-208 (SDK repo, tag v0.10.0)

Resonate docs: https://docs.resonatehq.io