Webhooks — How They Work

Push vs Poll · Registration · Delivery · Security · Retries · Real-world patterns

1 · What & Why
2 · Flow
3 · vs Polling
4 · Payload & Headers
5 · Security
6 · Retries & Failures
7 · Real Patterns
The core idea
Regular API — you ask them
Your server → sends GET request → their server responds with current state. You control the timing. You pay the cost of every request, even when nothing changed.
Webhook — they tell you
Their server → sends POST request → your server when something happens. They control the timing. Zero cost until there's real data to send. Your server just waits.
The one-liner
"Don't call us, we'll call you."

You give GitHub / Stripe / Twilio a URL. When a PR is opened / payment succeeds / SMS arrives — they HTTP POST a JSON payload to that URL immediately.
When webhooks exist vs when to poll
SituationUse
Payment completed (Stripe)✓ Webhook — event-driven
New PR opened (GitHub)✓ Webhook — immediate
Inbound SMS/call (Twilio)✓ Webhook — real-time
CI build finished✓ Webhook — push
API that doesn't support webhooksPoll — no choice
Need historical backfillPoll or REST query
High-frequency metricsPoll or streaming
Webhook vs related patterns
PatternDirectionWhen
WebhookServer → your server (HTTP POST)Event occurred
PollingYour server → server (HTTP GET)On schedule
WebSocketBidirectional persistentOngoing realtime
SSEServer → browser (stream)Server→browser push
Message queueAsync decoupledInternal services

The complete lifecycle: registration → event → delivery → acknowledgement. Every webhook follows this exact pattern.

Step 0 / 9
🧑‍💻
Developer
registers URL
once
Setting up
🖩
Your Server
webhook endpoint
/webhooks/github
Listening
🌐
GitHub
event source
sends events
Idle
Webhook flow log
Press Next to walk through the complete webhook lifecycle…
What's happening at each phase
Three phases
Phase 1 — Registration (once)
You tell GitHub "POST to https://api.yourdomain.com/webhooks/github when events happen." GitHub stores your URL and a secret.

Phase 2 — Event (async)
Developer opens a PR. GitHub's event system fires. Your URL is looked up. A POST is enqueued.

Phase 3 — Delivery + Ack
GitHub POSTs JSON to your URL. You process it. Return HTTP 200. GitHub marks delivery successful.

Same problem — "tell me when a payment succeeds" — solved two completely different ways.

Polling
Your server asks every N seconds
GET /payments/{id}/status every 5 seconds. 99% of requests return "still pending". Wastes compute, API quota, and adds latency.
Press "Show Polling" to animate…
VS
Webhook
They call you exactly once, immediately
POST /webhooks/stripe fires the moment payment succeeds. Zero wasted requests. Latency = milliseconds, not polling interval.
Press "Show Webhook" to animate…
DimensionPollingWebhook
LatencyUp to polling interval (5s–60s)Milliseconds after event
Wasted requestsMost requests return "no change"Zero — only fires on events
Server loadConstant — even when idleZero at rest
ComplexitySimple — just a loopNeed public URL, handle retries
Works behind firewallYesNo — must be publicly reachable
Can backfill historyYes — query historical endpointNo — missed events are gone
API rate limitsBurns quota constantlyOnly on real events

A webhook is just an HTTP POST with a JSON body and provider-specific headers. Understanding the structure helps you write robust handlers.

GitHub webhook payload (pull_request event)
"action": "opened", "number": 42, "pull_request": { "id": 1234567890, "title": "feat: add webhook support", "state": "open", "user": { "login": "harish" }, "head": { "sha": "abc123" }, "merged": false }, "repository": { "full_name": "harish/myrepo" }, "sender": { "login": "harish" }
HTTP headers sent by GitHub
# Every GitHub webhook POST includes: X-GitHub-Event: pull_request X-GitHub-Delivery: 7c9a3b40-1234-... ← unique ID X-Hub-Signature-256: sha256=a1b2c3... ← HMAC Content-Type: application/json User-Agent: GitHub-Hookshot/abc123
Stripe webhook payload
"id": "evt_1OXvBc...", "type": "payment_intent.succeeded", "created": 1706000000, "data": { "object": { "id": "pi_1OXvBc...", "amount": 2999, "currency": "usd", "status": "succeeded" } }
Handler skeleton (Go)
func HandleWebhook(w http.ResponseWriter, r *http.Request) { // 1. Read body BEFORE parsing (need raw bytes for HMAC) body, _ := io.ReadAll(r.Body) // 2. Verify signature FIRST — reject before doing any work sig := r.Header.Get("X-Hub-Signature-256") if !verifyHMAC(body, sig, secret) { http.Error(w, "forbidden", 403) return } // 3. Parse event type eventType := r.Header.Get("X-GitHub-Event") // 4. Respond 200 IMMEDIATELY — before processing // GitHub will retry if you don't ack within 10s w.WriteHeader(200) // 5. Process async — don't block the response go processEvent(eventType, body) } func verifyHMAC(body []byte, sig, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expected)) }
⚠ Idempotency is critical
Providers retry on failure. You will receive the same event multiple times. Always deduplicate on the delivery ID before processing.

if seen(deliveryID) { return 200 } // already processed

A public URL accepting POST requests is an attack surface. Without proper security, anyone can forge fake events. Three layers of defense.

Security checklist
🔑
HMAC Signature Verification Provider signs payload with your shared secret using HMAC-SHA256. You verify before doing any work. Prevents forged requests from unknown senders.
🕒
Timestamp Replay Protection Check X-Timestamp header. Reject if event is more than 5 minutes old. Prevents attackers replaying a captured valid request hours later.
🆔
Idempotency (Delivery ID) Store delivery IDs you've processed. Return 200 immediately on duplicate — don't reprocess. Providers retry on failure; duplicates are guaranteed.
🔒
HTTPS Only Never accept webhooks over HTTP. Payload in transit is readable. Signature can be stolen and replayed. Providers often refuse HTTP endpoints outright.
⏱️
Ack Fast, Process Async Return 200 within 5–10 seconds or the provider retries. Do the actual work in a background goroutine / queue. Never block the HTTP response on DB writes.
🛡️
IP Allowlist (optional) GitHub/Stripe publish their webhook IP ranges. Block everything else at WAF/firewall level. Defense-in-depth — signature check is still mandatory.
HMAC verification — the math
How signature works
Setup (once): You and the provider share a secret key. Never transmitted again.

On event: Provider computes HMAC-SHA256(secret, raw_body) and sends it in a header.

On receipt: You compute the same HMAC with your stored secret. If it matches the header — request is authentic. If not — reject with 403.
Secure handler pattern (Go)
// WRONG — parses JSON before verifying json.Unmarshal(body, &event) // ← attacker controls this verifySignature(body, sig) // ← too late, already trusted // CORRECT — verify FIRST, always body, _ := io.ReadAll(r.Body) // raw bytes if !verify(body, sig, secret) { w.WriteHeader(403) return // reject immediately } json.Unmarshal(body, &event) // now safe to parse // Replay protection ts := r.Header.Get("X-Timestamp") age := time.Since(parseUnix(ts)) if age > 5*time.Minute { w.WriteHeader(400) // stale event — reject return } // Idempotency deliveryID := r.Header.Get("X-GitHub-Delivery") if db.AlreadySeen(deliveryID) { w.WriteHeader(200) // already processed — ack return } db.MarkSeen(deliveryID)

What happens when your endpoint is down or returns an error? Every serious provider has a retry policy with exponential backoff. Understanding this is essential for reliable systems.

Delivery attempt timeline
Select a scenario above to visualise delivery attempts…
GitHub retry policy
AttemptDelayTotal elapsed
1stImmediately0s
2nd~5 min5m
3rd~30 min35m
4th~2 hours2h 35m
5th~8 hours~11h
6th–10thEvery 10hup to 72h
DeadAfter 72hEvent lost
What causes a retry?
• Non-2xx HTTP response (4xx or 5xx)
• Connection timeout (no response within 10s)
• Connection refused (port closed)
• TLS handshake failure

What does NOT retry: HTTP 200 (even if your app crashed after acking). Always ack with 200 first, then process.
Events can be lost
After the retry window (72h for GitHub), the event is gone. For critical events (payments, auth) always implement a reconciliation job that periodically queries the REST API to catch anything missed.

How webhooks are wired in production systems. Includes the fan-out queue pattern, local dev tunnels, and Cloudflare Workers as a webhook gateway.

Production pattern — queue fan-out
# Instead of processing inline (risky): POST /webhook → handler → DB write → email → slack ↑ if any step fails, GitHub retries ALL of it # Better: ack immediately → enqueue → workers POST /webhook → verify sig → return 200 → enqueue(event) ↓ queue worker 1: DB write queue worker 2: send email queue worker 3: slack notify # Workers retry independently, webhook is just a receiver
Cloudflare Workers as webhook gateway
// workers/webhook.ts — receives & routes export default { async fetch(req: Request, env: Env) { // 1. Verify GitHub HMAC immediately const sig = req.headers.get('X-Hub-Signature-256') const body = await req.text() if (!verify(body, sig, env.WEBHOOK_SECRET)) return new Response('forbidden', { status: 403 }) // 2. Ack immediately const event = JSON.parse(body) // 3. Fan out to Cloudflare Queue await env.WEBHOOK_QUEUE.send({ type: req.headers.get('X-GitHub-Event'), deliveryId: req.headers.get('X-GitHub-Delivery'), payload: event }) return new Response('ok', { status: 200 }) } }
Local dev — exposing localhost
# cloudflared tunnel (free, no account needed) cloudflared tunnel --url http://localhost:8080 # → https://random-word.trycloudflare.com # ngrok (popular, needs account for custom domain) ngrok http 8080 # → https://abc123.ngrok.io # Use the https URL as your webhook endpoint in GitHub/Stripe # Tunnel forwards requests to your local server
GitHub CI pipeline (common pattern)
PR opened → deploy preview
① PR opened in GitHub
② GitHub POSTs pull_request event to your webhook
③ Handler verifies sig, extracts head.sha + branch
④ Enqueues build job for that SHA
⑤ CI worker builds + deploys preview
⑥ Posts status check back to GitHub API
⑦ GitHub shows ✓ or ✗ on the PR
Stripe payment (critical path)
Payment confirmed without polling
① User submits payment → Stripe charges card
② Stripe POSTs payment_intent.succeeded
③ Handler verifies Stripe signature
④ Checks idempotency — event ID seen before?
⑤ If new: fulfil order, send receipt email
⑥ Returns 200

⚠ Never fulfil based on Stripe redirect alone — redirects can be faked. Always wait for the webhook.
Failure recovery — reconciliation
Assume you'll miss events
Even with retries, events can be lost (provider outage, DNS failure). For critical paths:

• Run a daily job querying the REST API for events in the last 24h
• Compare against your DB — process anything missing
• Design your handler to be idempotent so reprocessing is safe

GET /payments?created[gte]=yesterday → reconcile