Before this:Polling vs pushClients and servers
Webhooks
Key takeaways A webhook flips the roles: you register a URL, and when an event happens the service makes an HTTP POST to you — the server becomes a client, and your program becomes a server. No connection stays open, which makes webhooks ideal for server-to-server integration — but delivery over the open internet means your receiver must answer fast, expect retries and duplicates (at-least-once delivery ⇒ handle events idempotently), and verify that each delivery really came from the sender.
WebSockets and SSE both hold a connection open. Webhooks take the third path: no standing connection at all — just an agreement that when something happens, the service will send a request to a URL you chose. It’s how services integrate with each other all over the industry, and it’s one of the delivery options for GopherTrunk’s call events (Unit 6).
The role reversal
Everything from clients and servers still
applies — the roles just swap per direction. You tell the scanner daemon (via its
config or API): “when a call starts, POST to
https://myserver.example/hooks/scanner.” Later, on its own initiative:
POST /hooks/scanner HTTP/1.1
Host: myserver.example
Content-Type: application/json
X-Event-Type: call.start
X-Signature: sha256=7fd2a1...
{"system":"county-p25","talkgroup":1201,"label":"County Fire Dispatch","start":"2026-08-21T14:03:12Z"}
Your endpoint answers 200 OK and does its thing — a push notification, a
database row, a light turning on. The elegance: between events, nothing
exists — no socket, no polling loop, no state but the registered URL. The
catch: you now operate an internet-reachable HTTP server, with everything that
implies (exposing a service safely
is required reading before doing this from home).
Delivery is best-effort — so it retries
The sender can’t know your endpoint is healthy; it only knows whether the POST got a 2xx. So every serious webhook sender follows the same discipline, and your receiver must anticipate each piece of it:
- Timeouts are short. If your endpoint takes 30 s to respond, the sender
gives up. Therefore: acknowledge first, work later — return
200as soon as the event is durably queued, and do the slow work (transcoding, notifying, writing) afterwards. - Failures are retried, usually with growing delays over minutes or hours. Good senders treat any non-2xx or timeout as “try again later.”
- Retries mean duplicates. A delivery whose response got lost will be sent again — the event arrived twice. This is at-least-once delivery, and it’s a law of the pattern, not a bug: the only alternatives are at-most-once (events silently lost) or exactly-once (impossible without coordination both ends rarely have).
Rule of thumb: process webhook events idempotently — use the event’s ID to make the second arrival of the same event a no-op. This is idempotence from Unit 2, now as a receiver’s survival skill.
Trust: anyone can POST to a URL
Your webhook endpoint is a public URL, and anything on the internet can send
requests to it. An unverified receiver will happily act on forged “call.start”
events from a script kiddie’s laptop. The standard defence is a signature:
sender and receiver share a secret; the sender computes an HMAC of each payload
and ships it in a header (the X-Signature above); the receiver recomputes and
compares before trusting a byte of the body. Two details matter in practice:
sign the raw body bytes (re-serialized JSON may differ harmlessly but hash
differently), and use a constant-time comparison. TLS on your endpoint is table
stakes — it protects the payload in transit, but only the signature proves who
sent it (the cybersecurity module
covers the HMAC machinery itself).
Webhooks vs the open-connection transports
| Webhook | SSE / WebSocket | |
|---|---|---|
| Receiver is | a public HTTP server | an outbound client |
| Standing state | none — just a registered URL | an open connection per client |
| Works behind NAT/firewall | no (must be reachable) | yes (outbound only) |
| Missed-event story | sender retries for you | resume/replay (SSE) or DIY |
| Best for | server-to-server integrations | browsers, dashboards, anything behind NAT |
That NAT row decides most real cases: a phone app or a browser tab can’t receive webhooks, and a cloud service integrating with another cloud service shouldn’t hold a million idle sockets. Pick the transport whose receiver role your situation can actually play.
Quick check: your webhook receiver got the same call.start event twice. What does this most likely mean?
Recap
- A webhook is an HTTP callback: you register a URL, the service POSTs events to you — roles reversed, no standing connection.
- Receivers must acknowledge fast and work later; senders time out quickly and retry failures.
- Delivery is at-least-once: duplicates are normal, so process events idempotently, keyed by event ID.
- Verify signatures (HMAC over the raw body) — a public URL will receive forgeries eventually; TLS alone doesn’t prove the sender.
- Webhooks fit server-to-server; anything behind NAT or in a browser should use SSE or WebSockets instead.
Next up: Streaming & backpressure.