To add the n8n side and the conflict path to the unique-constraint approach above:
Claim. Make it one statement in a Postgres node (Execute Query), straight after the Webhook node:
INSERT INTO webhook_claims (event_key)
VALUES ($1)
ON CONFLICT (event_key) DO NOTHING
RETURNING event_key;
event_key is the primary key or has a unique constraint. Pass the key through Query Parameters, not by building the SQL string. With ON CONFLICT a duplicate isn’t an error, so you don’t need error branches for the normal case.
Conflict path. The first execution gets a row back; a duplicate gets none. What the node outputs for “no rows” can differ by node version, so test for the value rather than for item count: turn on Always Output Data on the node, then add an IF node checking that {{ $json.event_key }} exists.
- True: do the work.
- False: stop, and still answer the sender with a 2xx so it doesn’t keep retrying.
Crashes. A plain claim will block a retry if the execution dies after claiming. If you need retries, add status and claimed_at columns and let a stale or failed claim be taken again:
INSERT INTO webhook_claims (event_key, status, claimed_at)
VALUES ($1, 'processing', now())
ON CONFLICT (event_key) DO UPDATE
SET status = 'processing', claimed_at = now()
WHERE webhook_claims.status = 'failed'
OR (webhook_claims.status = 'processing'
AND webhook_claims.claimed_at < now() - interval '15 minutes')
RETURNING event_key;
Set the interval longer than your slowest run, and write status = 'done' as the last step.
Data Tables. I haven’t tested them under concurrent writes, so I wouldn’t use them for this until you’ve run the test below.
To verify: send 20 parallel requests with the same key (for example curl in a loop with &). You should see one row in the table and one execution on the True branch; the other 19 go to False.
Minimolo