All articles

Fundamentals · July 25, 2026 · 10 min read

Anatomy of a webhook request: headers, payload and response

Behind all the vocabulary — events, deliveries, signatures — a webhook is just an HTTP request. If you already understand what a webhook is and how it works, the next step is learning to read a delivery end to end: every line, from the method to the body, carries information that unlocks debugging, deduplication and security. Let's dissect a real request, piece by piece.

A typical delivery, in its raw form, looks like this:

GitHub webhook delivery (push) · full request
POST /webhooks/github HTTP/1.1
Host: your-app.com
User-Agent: GitHub-Hookshot/f05835d
Content-Type: application/json
Content-Length: 7291
X-GitHub-Event: push
X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958
X-Hub-Signature-256: sha256=d57c68ca6f92289e6987922ff26938930f6e66a2d161ef06abdf1859230aa23c

{
  "ref": "refs/heads/main",
  "before": "9049f1265b7d61be4a8904a9a27120d2064dab3b",
  "after": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c",
  "repository": { "full_name": "acme/api" },
  "pusher": { "name": "octocat" },
  "commits": [ "..." ]
}

Three blocks: the request line (method and path), the headers and the body. Each answers a different question: what should I do with this?, is it trustworthy and is it new? and what exactly happened?

The request line: why it's almost always POST

Webhooks carry a new fact — an event — which is why they use POST: it's the HTTP method for submitting data the server should process. The path (/webhooks/github in the example) is yours to choose when you register the URL; many teams create one path per provider, which simplifies routing, logs and permissions.

The classic exception is endpoint verification. Some providers, before sending any events, confirm the URL really is yours. Meta (WhatsApp Business, Instagram) performs a GET handshake: GET /webhooks?hub.mode=subscribe&hub.verify_token=YOUR_TOKEN&hub.challenge=158201444. Your server must check the hub.verify_token and reply 200 with the hub.challenge value in the body. If your endpoint only accepts POST, that verification fails — and the integration never gets off the ground.

The headers, group by group

Headers are the most underrated part of a delivery. They fall into three groups with distinct jobs.

Event and delivery identification

Before you even open the body, the headers already tell you what kind of event arrived and which delivery this is:

  • X-GitHub-Event — the event type (push, pull_request…). Lets you route processing without parsing the JSON.
  • X-GitHub-Delivery — a UUID that is unique per delivery. If GitHub resends the same event, the UUID changes; it's the identifier you quote in a support ticket.
  • webhook-id — in the Standard Webhooks spec (adopted by a growing number of providers), this header identifies the event and stays the same across retries: it's the ideal deduplication key.

The distinction matters: a delivery ID changes on every attempt; an event ID doesn't. To implement idempotency you want the latter — if your provider only offers the former, use the id from inside the body.

Signature and security

Your endpoint is public, so anyone can send a POST to it. The signature header is what proves the request really came from the provider: an HMAC computed over the body with a secret only the two of you know. Each provider packages it differently:

ProviderHeaderFormat / algorithm
GitHubX-Hub-Signature-256HMAC-SHA256 of the body, hex-encoded, prefixed with sha256=
StripeStripe-Signaturet=timestamp,v1=hex — HMAC-SHA256 over {t}.{body}, the timestamp is part of the calculation
ShopifyX-Shopify-Hmac-Sha256HMAC-SHA256 of the body, Base64-encoded
Mercado Pagox-signaturets=…,v1=hex — HMAC-SHA256 over a manifest built from data.id, x-request-id and the timestamp
Standard Webhookswebhook-signaturev1,base64 — HMAC-SHA256 over {id}.{timestamp}.{body}

Notice the pattern: when the timestamp is part of the calculation (Stripe, Mercado Pago, Standard Webhooks), the provider is closing the door on replay attacks — resending an old, captured delivery. The full verification mechanism, including constant-time comparison and clock tolerance, is covered in Webhook security: HMAC signatures and best practices.

Content and metadata

  • Content-Type — almost always application/json, but don't count on it blindly. GitHub can be configured to send application/x-www-form-urlencoded, delivering the JSON inside a payload= field. Legacy systems (and some older payment gateways) still send XML.
  • Content-Length — the body size in bytes. Useful for detecting payloads truncated by proxies and for setting limits on your server.
  • User-Agent — identifies the sender (GitHub-Hookshot/f05835d, Stripe/1.0 (+https://stripe.com/docs/webhooks)…). Fine for coarse filtering and stats, but it's spoofable — never use it as authentication.

The body: envelope, event and data

Even though every provider has its own schema, most converge on the same envelope: an identifier, a type, a timestamp and the event data. It's the minimum contract you need to route, deduplicate and order.

Typical envelope
{
  "id": "evt_1QxK2mLkdIwHu7ix",      // deduplication
  "type": "payment.approved",        // routing
  "created_at": "2026-07-25T14:32:07Z", // ordering / tolerance
  "data": { "...": "..." }           // the event itself
}

The big split is in what data contains. There are two philosophies:

  • Fat payload — the event carries the complete object. GitHub and Stripe work this way: the push brings the commits, the payment event brings the entire payment_intent object. You process without extra calls, but the data may be stale if events arrive out of order.
  • Thin payload — the event carries only references, and you query the API for the current state. That's Mercado Pago's model:
Mercado Pago webhook · thin payload
{
  "id": 117554765,
  "type": "payment",
  "action": "payment.updated",
  "date_created": "2026-07-25T14:32:07Z",
  "data": { "id": "1316643861" }
}

A thin payload forces an API call (GET /v1/payments/1316643861) before any decision — more latency and one more failure point, but the state you read is always current, and a leaked payload exposes less data. Knowing which philosophy your provider follows shapes your consumer's architecture.

Raw body: the number one gotcha

The HMAC signature is computed over the exact bytes of the body. Not over "the equivalent JSON" — over the byte sequence. If your framework parses the body before you verify (the classic global express.json()), what you're left with is a JavaScript object; re-serializing it with JSON.stringify changes whitespace, key order or escapes — and the signature never matches again. It is by far the most common cause of "webhook verification only failing in production".

Express · preserving the raw body
// ❌ with the global parser, the original bytes are lost
app.use(express.json());

// ✅ option 1 — raw body only on the webhook route
app.post(
  "/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // req.body is a Buffer with the exact bytes
    const ok = verifySignature(req.body, req.headers["stripe-signature"]);
    if (!ok) return res.sendStatus(400);
    res.sendStatus(200);
    processLater(JSON.parse(req.body));
  }
);

// ✅ option 2 — keep express.json() and capture the raw body via "verify"
app.use(
  express.json({
    verify: (req, _res, buf) => {
      req.rawBody = buf;
    },
  })
);

The same principle applies to Next.js (disable the route's body parser), Fastify (rawBody via plugin) and any other framework: verify against the raw buffer, parse afterwards.

The response: what the provider expects from you

  • A 2xx status acknowledges receipt. 200 and 204 are the usual ones; the response body is ignored by most providers — don't waste time crafting one.
  • Be fast: the clock is ticking. Typical timeouts range from 5 seconds (Shopify) to 10–30 seconds (GitHub, Stripe and others). Blow the deadline and the delivery counts as failed — even if your code finished later. Hence the rule: respond immediately, process asynchronously.
  • Redirects count as failures. Most providers don't follow 3xx. A redirect from http to https or between domains with/without www is enough to "lose" webhooks — register the exact final URL.
  • 4xx and 5xx feed the retry policy. Both trigger new attempts with backoff, but persistent failures have consequences: GitHub, Stripe and Shopify disable or remove endpoints that return nothing but errors for days. A useful detail from the Standard Webhooks spec: responding 410 Gone signals "stop sending here".

Summary

  • A webhook is POST + headers + body; the verification GET (Meta's challenge) is the exception your endpoint needs to support.
  • Headers answer "what is it, is it new, is it trustworthy": event type, delivery/event ID and HMAC signature — each provider with its own format.
  • The body follows an envelope (id, type, timestamp, data) and comes either "fat" (GitHub, Stripe) or "thin" (Mercado Pago) — the choice shapes your architecture.
  • Verify the signature against the raw body (exact bytes), never against re-serialized JSON.
  • Respond 2xx within milliseconds, with no redirects; repeated errors trigger retries and can get your endpoint disabled.