Every webhook system in the world delivers at least once, and everybody is surprised the first time. The event arrives twice, the customer is messaged twice, and the bug report says the webhook is broken. It is not: it is doing exactly what it promised.
Why a repeat is normal
- Your handler was slow, so the sender gave up waiting and tried again.
- Your handler answered 500 after doing the work.
- The connection dropped after you replied but before the reply arrived.
- A deploy restarted the process mid-request.
Only one of those is a bug in your code, and all four produce a duplicate.
The fix is a unique constraint
CREATE TABLE handled ( event_id TEXT PRIMARY KEY, handled_at TIMESTAMPTZ NOT NULL DEFAULT now() );
Insert before you act. If the insert conflicts, somebody already handled it and you are done. This survives the restart in the middle that an in-memory set does not, and it needs no library, no lock and no coordination between workers.
Two things people get wrong after that
They answer 200 after the work instead of before it, which turns a slow handler into a retried one. And they verify the signature after reading the body, which means they have already parsed input from whoever found the URL. Signature first, 200 second, work third.