Handle out-of-order webhook events

I’m building an n8n workflow that receives webhook events from an external service. The same record can generate multiple events, but sometimes they arrive out of order because of retries or network delays.
Example: Order Updated → status = shipped
Order Updated → status = delivered
but n8n receives delivered first and shipped afterward.
My current PostgreSQL update is simply:
UPDATE orders
SET status = $2,
updated_at = NOW()
WHERE id = $1;
Would you recommend using an event timestamp, sequence number, or version column so an older event can’t overwrite newer data?
How would you implement that safely in PostgreSQL?

Describe the problem/error/question

What is the error message (if any)?

Please share your workflow

(Select the nodes on your canvas and use the keyboard shortcuts CMD+C/CTRL+C and CMD+V/CTRL+V to copy and paste the workflow.)

Share the output returned by the last node

Information on your n8n setup

  • n8n version:
  • Database (default: SQLite):
  • n8n EXECUTIONS_PROCESS setting (default: own, main):
  • Running n8n via (Docker, npm, n8n cloud, desktop app):
  • Operating system:

@Gabby_Timothy use a sequence number or version number whenever the API provides one. It’s much safer than relying only on timestamps.

UPDATE orders
SET
status = $2,
version = $3,
updated_at = NOW()
WHERE id = $1
AND version < $3;

If an older event arrives later, the version < $3 condition prevents it from overwriting the newer state.

If the API only provides timestamps, you can use: UPDATE orders
SET status = $2,
updated_at = $3
WHERE id = $1
AND updated_at < $3;

also store the incoming events separately if you need auditing or replay.

If I use a version column to prevent older events from overwriting newer ones, how would you handle two webhook events arriving at almost the exact same time?

Would version < $3 alone be enough, or should I also use a PostgreSQL transaction or row-level locking to make the update completely safe under concurrent n8n executions? @Keira_Becky

@Gabby_Timothy version < $3 is usually enough to prevent an older event from overwriting a newer one, because PostgreSQL evaluates the condition atomically.

UPDATE orders
SET
status = $2,
version = $3,
updated_at = NOW()
WHERE id = $1
AND version < $3;

If two n8n executions hit the same order at almost the same time, PostgreSQL handles the row update sequentially. Once the higher version is written, the older update no longer satisfies version < $3.

only add FOR UPDATE when you need to perform multiple dependent database operations in the same transaction.

Hi @Gabby_Timothy
A guarded UPDATE matches zero rows when the row does not exist yet, Postgres reports UPDATE 0 with no error, and the event is gone. Out-of-order delivery makes that case routine, since the event that creates the record can arrive after the one that updates it. Collapse insert and guard into one statement:

INSERT INTO orders (id, status, version, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status, version = EXCLUDED.version, updated_at = NOW()
WHERE orders.version < EXCLUDED.version
RETURNING *;

id needs a primary key or unique index for ON CONFLICT to target. A missing row is inserted, a newer event is applied, a stale one is skipped, and the strict < also swallows an exact replay of the same version. Postgres locks the conflicting row when DO UPDATE runs and guarantees an atomic insert-or-update outcome under concurrency, so this holds for your simultaneous-arrival case too. RETURNING only hands back a row the statement wrote, a stale event comes back empty, so the next node can branch on whether anything was applied.
In the Postgres node run it with Query Parameters and set Query Batching to Independently. Single Query folds all incoming items into one statement, and two events for the same order in one batch fail with “ON CONFLICT DO UPDATE command cannot affect row a second time”.