Idempotency - Process Event Once
A workflow that takes an event with a unique ID and handles it exactly once. Subsequent deliveries of the same ID — whether from a retrying webhook source, a misconfigured fan-out, or a manual replay — see the row already exists and skip the work. Make it safe for callers to make a multiple of the same request to your workflow.
Workflow JSON
Section titled “Workflow JSON”Copy this and paste it into a new workflow with Cmd+V:
{ "name": "process-event-once", "description": "Handle each event exactly once. The unique event ID is the dedupe key; atomic-upsert claims the event in a single operation, so concurrent or retried deliveries can't both pass the check.", "initial": { "eventId": "evt_abc123", "payload": { "userId": "user_42", "action": "subscribe" } }, "steps": [ { "stepId": "claim-event", "stepType": "data-store.atomic-upsert", "input": { "tableName": "processed_events", "key": "{{ initial.eventId }}", "insertValue": { "claimedAt": "{{ $util.now }}", "payload": "{{ initial.payload }}" }, "set": [] } }, { "stepId": "branch-on-first-delivery", "stepType": "core.if", "input": { "condition": { "==": [{ "var": "claim-event.inserted" }, true] }, "then": [ { "stepId": "process-event", "stepType": "core.log", "input": { "message": "First delivery of event {{ initial.eventId }}. Replace this step with the real work." } } ], "else": [ { "stepId": "duplicate-skip", "stepType": "core.log", "input": { "message": "Event {{ initial.eventId }} already processed at {{ claim-event.value.claimedAt }}. Skipping." } } ] } } ]}Connections needed: none.
Trigger: a Webhook trigger whose payload carries a unique-per-event field. The example uses eventId; rename to match whatever your source sends.
Table: a processed_events table will be created on first run. Each row’s key is the event ID; the value records the timestamp the event was claimed and the original payload for audit.
How it works
Section titled “How it works”Two steps:
- claim-event —
data-store.atomic-upsertagainstprocessed_eventswith the event ID as the key andset: []as the update mutation. The insert branch runs only if the row didn’t already exist; the conflict branch is a no-op. - branch-on-first-delivery — if
claim-event.insertedistrue, run the work; otherwise log the duplicate and skip.
The insert is one atomic operation, so two concurrent deliveries of the same event ID will see exactly one inserted: true. Every other delivery sees inserted: false and the existing row’s value.
What to customize first
Section titled “What to customize first”- Replace the log step with your real work. The
core.logstep in thethenbranch is a placeholder. Put the HTTP call, downstream workflow, or side effect this event should trigger in its place. - Add a TTL. The dedupe table grows indefinitely. If events become safe to re-process after some window (say, 24 hours), add an
expirationblock to the upsert so claimed rows expire on their own. - Record outcomes. If you want to know whether the work succeeded or errored on the first delivery, add an
atomic-updateafter the work step to recordprocessedStatusandcompletedAton the same row.
Related recipes
Section titled “Related recipes”- Rate-Limited HTTP Calls — for when you also need to throttle, not just dedupe
- Async Queue Dispatcher — for events that need to drain at a controlled rate rather than fire-and-forget