Skip to content

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.

Copy this and paste it into a new workflow with Cmd+V:

process-event-once.json
{
"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.

Two steps:

  1. claim-eventdata-store.atomic-upsert against processed_events with the event ID as the key and set: [] as the update mutation. The insert branch runs only if the row didn’t already exist; the conflict branch is a no-op.
  2. branch-on-first-delivery — if claim-event.inserted is true, 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.

  • Replace the log step with your real work. The core.log step in the then branch 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 expiration block 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-update after the work step to record processedStatus and completedAt on the same row.