Webhooks

How outbound webhook deliveries are signed, and how a receiver verifies a signature.

Webhooks run in both directions, and both use the same signing scheme:

  • Outbound — Foxguide POSTs an event to a URL you own. You verify our signature.
  • Inbound — you POST to a URL Foxguide issues you. We verify yours, if you configure it.

The signature scheme

X-Webhook-Signature: sha256=HMAC_SHA256(secret, "<unix-seconds>.<raw-body>")
X-Webhook-Timestamp: <unix-seconds>

Three details decide whether your implementation works:

  1. The signed string is ${timestamp}.${body} — the timestamp, a literal ., then the body. Not the body alone.
  2. The body is the RAW bytes as received, before any JSON parse. Re-serialising with JSON.stringify(parsedBody) produces a different byte sequence — key order, whitespace, unicode escaping — and therefore a different digest. Capture the raw body in your framework’s body parser and HMAC over the buffer.
  3. The digest is hex, prefixed sha256=. Compare the whole header value with a constant-time comparison.

The timestamp carries a ±5 minute tolerance in both directions. A stale timestamp is refused because it bounds how long a captured request stays replayable; a far-future one is refused for the same reason, since otherwise a sender could mint a signature valid indefinitely.

Verifying an outbound delivery

import crypto from 'node:crypto';

// Express: capture the raw bytes, do not rely on the parsed object.
app.post('/hooks/foxguide',
  express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }),
  (req, res) => {
    const ts = req.get('X-Webhook-Timestamp');
    const presented = req.get('X-Webhook-Signature');
    if (!ts || !presented?.startsWith('sha256=')) return res.sendStatus(401);
    if (Math.abs(Math.floor(Date.now() / 1000) - Number(ts)) > 300) return res.sendStatus(401);

    const expected = 'sha256=' + crypto
      .createHmac('sha256', process.env.FOXGUIDE_WEBHOOK_SECRET)
      .update(ts + '.')
      .update(req.rawBody)             // the ORIGINAL bytes
      .digest('hex');

    const a = Buffer.from(presented), b = Buffer.from(expected);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

    res.sendStatus(200);               // acknowledge fast, process asynchronously
  });

Outbound deliveries

Every delivery carries:

headermeaning
X-Webhook-Signaturethe digest above — present when the endpoint has a signing secret
X-Webhook-Timestampthe unix seconds the signature was computed over
Idempotency-Keystable across every retry of the same delivery
X-Correlation-Ida trace id, fresh per attempt — for correlating logs, not for dedupe

Deduplicate on Idempotency-Key, never on X-Correlation-Id. A retried delivery reuses the idempotency key and mints a new correlation id, so a receiver keyed on the correlation id will process the same event twice. Assume at-least-once delivery and make your handler idempotent.

Acknowledge with a 2xx quickly and do the work afterwards. A slow handler looks like a failed delivery and gets retried.

Inbound endpoints

Foxguide issues you a receiver URL:

POST https://api.foxguide.io/webhooks/{endpointId}/{token}
Content-Type: application/json

The token segment is the credential — it is compared in constant time, and the URL is therefore a secret. Treat it like a password: it is rotatable, so rotate it rather than deleting the endpoint if it leaks.

Optionally, turn on signature verification for the endpoint. Once required it is fail-closed: an unsigned or wrongly-signed request is refused rather than falling back to token-only, so a leaked URL alone is no longer enough to forge an event. Sign your request with the same scheme described above, using the endpoint’s secret.

Responses

statusbody errormeaning
200{"status":"accepted"} — the delivery was queued
401WEBHOOK_UNAUTHORIZEDbad token, or a required signature that did not verify
410WEBHOOK_DISABLEDthe endpoint exists but is not accepting deliveries
413PAYLOAD_TOO_LARGEthe body exceeds 1MB
415UNSUPPORTED_MEDIA_TYPEnot Content-Type: application/json with a JSON body
429RATE_LIMIT_EXCEEDEDmore than 100 deliveries per minute to this receiver
500INTERNAL_ERRORour side failed — safe to retry

A signature failure and a bad token answer identically, both 401. That is deliberate: a distinct code would tell a caller holding a guessed URL that their token was right and only the signature was wrong. The specific reason is recorded in your endpoint’s delivery log, where you as the owner can still read it.

One retired name

X-Foxguide-Signature is retired. It was a body-only variant with no timestamp and it is neither emitted nor accepted anywhere. If you are following an older integration note that mentions it, that note describes a scheme this API no longer speaks — use X-Webhook-Signature as documented above.

← All documentation