Guide 08

APIs and webhooks for bots: receiving, verifying and processing events safely

A webhook is an HTTP request from a stranger that claims to be a platform. Verify it, acknowledge it, and process it exactly once.

Guide3 min readUpdated

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

  1. Read the raw body as bytes before any JSON parsing.
  2. Verify the signature or secret header. Reject with 401 on failure.
  3. Check freshness: if the provider sends a timestamp, reject anything older than about five minutes.
  4. Deduplicate on the event ID.
  5. Enqueue the event and return 2xx immediately.
  6. 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 in webhook-signature: v1,....
  • Discord: Ed25519 public-key signature over timestamp + body.
  • Telegram: a static secret_token you 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-Agent and 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-After and published rate limits; see reliability and rate limits.

Check your understanding

1. What must the HMAC be computed over?
2. Which responses are worth retrying?