Three ways to get events
| Model | How | Pros | Cons |
|---|---|---|---|
| Polling | You call GET /events?since=... on a schedule |
Simple, no public endpoint | Latency, wasted requests |
| Webhooks | The provider POSTs to your URL |
Real time, serverless-friendly | Needs a public HTTPS endpoint and verification |
| WebSocket | Persistent connection | Lowest latency, bidirectional | Long-lived process, reconnect logic |
Anatomy of a good receiver
- Read the raw body as bytes before any JSON parsing.
- Verify the signature or secret header. Reject with
401on failure. - Check freshness: if the provider sends a timestamp, reject anything older than about five minutes.
- Deduplicate on the event ID.
- Enqueue the event and return 2xx immediately.
- Process in a worker with retries, then call the platform API to reply.
Verifying signatures
Most providers use HMAC-SHA256 with a shared secret. The critical detail is to sign the exact bytes received: if your framework parses and re-serializes JSON, whitespace and key order change and the signature fails.
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post("/webhooks/github", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => {
const sig = req.get("X-Hub-Signature-256") || "";
const mac = "sha256=" + crypto.createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET).update(req.body).digest("hex");
const a = Buffer.from(sig), b = Buffer.from(mac);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
queue.add({ id: req.get("X-GitHub-Delivery"), type: req.get("X-GitHub-Event"), event });
res.sendStatus(202);
});
Use a constant-time comparison (timingSafeEqual, hmac.compare_digest) so attackers cannot guess the signature byte by byte through timing. Some platforms use different schemes:
- Slack: HMAC of
v0:{timestamp}:{body}. - Standard Webhooks: HMAC of
{id}.{timestamp}.{body}, base64 inwebhook-signature: v1,.... - Discord: Ed25519 public-key signature over
timestamp + body. - Telegram: a static
secret_tokenyou chose, sent in a header.
Debug mismatches with the webhook tester.
Acknowledge fast, work later
Providers have short timeouts (Slack and Discord: 3 seconds; others 5 to 30) and retry on failure. If your handler calls an AI model or a slow API before responding, you will time out, the provider will retry, and you will process the same event several times. Put the event on a queue (Cloudflare Queues, SQS, Redis, a database table) and return 200 or 202. On serverless platforms that support it, waitUntil lets work continue after the response.
Idempotency
Assume at-least-once delivery. Store processed event IDs with a TTL longer than the provider's retry window and skip duplicates. For outgoing calls that create things (payments, tickets), send an idempotency key if the API supports it, so your own retries do not create duplicates.
Retries with backoff and jitter
When you call other APIs, retry only on transient failures (network errors, 429, 5xx), never on 400 or 401. Wait exponentially longer each time and add randomness so many clients do not retry in sync:
import random, time, httpx
def call_with_retry(fn, attempts=5, base=0.5, cap=30):
for i in range(attempts):
try:
r = fn()
if r.status_code == 429:
time.sleep(float(r.headers.get("Retry-After", base * 2 ** i)))
continue
if r.status_code < 500:
return r
except httpx.TransportError:
pass
time.sleep(random.uniform(0, min(cap, base * 2 ** i))) # full jitter
raise RuntimeError("gave up after retries")
Payload hygiene
- Limit body size (1 MB is generous for chat events).
- Validate the shape with a schema; ignore unknown fields rather than failing.
- Never trust IDs in the payload for authorization without checking them against your own records.
- Log event IDs and types, not full bodies with personal data.
Testing locally
Expose your local server with a tunnel (cloudflared tunnel --url http://localhost:3000, ngrok or similar), register the temporary URL as the webhook, and remember to switch back. Save real payloads (scrubbed of personal data) as fixtures and replay them in unit tests. Most providers also offer a "redeliver" button in their dashboards.
Calling APIs well
- Send a descriptive
User-Agentand keep credentials in headers, not query strings. - Set timeouts on every request (connect and read).
- Paginate with cursors and stop when the API says so.
- Respect
Retry-Afterand published rate limits; see reliability and rate limits.