A webhook receiver is a security boundary, not just an endpoint
Anyone who learns your webhook URL can POST to it. That makes three properties non-negotiable on the receiving side — build the payload here, but design the handler around these:
- Authenticity — verify the provider’s signature so you only act on payloads they actually sent.
- Idempotency — the same event will arrive more than once; processing it twice must not double-charge, double-ship, or double-email.
- Freshness — reject payloads old enough to be a replay of a captured request.
Verifying signatures correctly
The mechanics are simple and easy to get subtly wrong:
- Use the raw body. (See the FAQ above — this is the single most common failure.)
- Compare in constant time. A naive
==on signature strings leaks timing information that can be exploited to forge a signature byte by byte. Use a timing-safe comparison:crypto.timingSafeEqualin Node,hmac.compare_digestin Python. - Check the timestamp. Stripe-style schemes sign
timestamp.payloadand include the timestamp in the header. Reject anything outside a tolerance window (five minutes is typical) so a captured request can’t be replayed tomorrow.
Designing for duplicates
Every major provider documents at-least-once delivery — duplicates are a feature of reliability, not a bug. The defence is an idempotency key. Each event carries a stable id (evt_… from Stripe, the X-GitHub-Delivery UUID from GitHub). Record processed ids in a table with a unique constraint; if an insert collides, you’ve seen this event before — acknowledge with 2xx and do nothing else. This single table turns “we got charged twice during a provider retry storm” into a non-event.
Test the payload here, then test the transport
This builder validates and formats the JSON body so you can confirm shape and field names against a provider’s schema before you wire anything up. To exercise the full round trip, copy the generated payload and POST it to your endpoint — the cURL to Code tool will turn a curl -X POST into a snippet in your language. One caveat: a payload you build here won’t carry a valid provider signature, so point your handler’s signature check at test mode (or temporarily log-and-skip) when replaying hand-built payloads, and reserve real signature verification for traffic that genuinely came from the provider.
Live streams: WebSocket and SSE
The WebSocket / SSE mode above, or jump straight to it.
Reading WebSocket close codes like a protocol native
The close code is the first diagnostic fact of any dropped connection — and most dashboards never show it. The ones that matter:
| Code | Meaning | Usual culprit |
|---|---|---|
| 1000 | Normal closure | Clean shutdown — not an error |
| 1001 | Going away | Server restart or deploy |
| 1006 | Abnormal closure | Network/proxy/TLS failure, no handshake |
| 1008 | Policy violation | Auth rejected, origin not allowed |
| 1009 | Message too big | Frame exceeded server’s limit |
| 1011 | Server error | Unhandled exception server-side |
A reconnecting client should treat 1000/1001 as expected, back off exponentially on 1006, and stop retrying on 1008 — hammering an endpoint that rejected your credentials just gets your IP rate-limited.
WebSocket or SSE? Choose by direction
Both deliver real-time updates; the decision is traffic direction. Server-Sent Events are one-way (server → client), ride on plain HTTP, reconnect automatically, and pass through proxies and CDNs effortlessly — the right choice for feeds, notifications, progress bars, and LLM token streams. WebSockets are bidirectional with lower per-message overhead — necessary for chat, multiplayer state, and collaborative editing where the client talks back constantly. The honest heuristic: if you only need to receive, SSE is operationally simpler; teams reach for WebSockets by default and inherit connection-management complexity they didn’t need.
The SSE failure modes nobody warns you about
Because this client opens an EventSource when you give it an http:// or https:// URL, you can reproduce the three classic Server-Sent Events failures here rather than guessing at them from your app. All three look identical from inside application code — “the stream just stops” — and have completely different fixes.
The stream connects but nothing arrives until it ends. Your proxy is buffering. Nginx buffers proxied responses by default, so it holds the event stream until the response completes — which for a long-lived stream is never. The fix is proxy_buffering off; on that location, or having the application send X-Accel-Buffering: no. If events appear here but not through your load balancer, this is almost always why.
The seventh tab never connects. Over HTTP/1.1 browsers cap concurrent connections per origin at six, and an open SSE stream holds one for its entire life. Six tabs of your dashboard and the seventh hangs with no error. HTTP/2 multiplexes over a single connection and raises the ceiling to the server’s stream limit, so this failure quietly disappears on HTTP/2 and reappears the moment something in the path downgrades.
It reconnects forever and you never notice. EventSource reconnects automatically — that is the feature — which means a server returning errors produces a silent retry loop rather than a visible failure. Watch the log here: a healthy stream shows one open and then messages; a broken one shows repeated opens. The server controls the interval with a retry: field, and if it has been sending id: values the browser replays the last one in a Last-Event-ID header on reconnect, which your handler must honour or clients will miss events across every reconnect.
None of this applies to WebSockets, which is the point: the two protocols fail in different places, and a tester that only speaks one of them can only show you half the picture.
Testing authenticated endpoints
Browsers don’t let WebSocket clients set arbitrary headers, so real-world auth lands in one of three places: a token in the query string (wss://api.example.com/feed?token=... — simplest, but tokens leak into server logs), a ticket pattern (fetch a short-lived ticket over HTTPS, pass it in the URL — the production-grade answer), or the Sec-WebSocket-Protocol field abused as a token carrier. When a connection authenticates in your backend tests but fails from this tester, the server is usually reading auth from a cookie or header your test setup sent implicitly — re-test with the token explicitly in the URL to confirm which mechanism the endpoint really uses.