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:
- The signed string is
${timestamp}.${body}— the timestamp, a literal., then the body. Not the body alone. - 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. - 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:
| header | meaning |
|---|---|
X-Webhook-Signature | the digest above — present when the endpoint has a signing secret |
X-Webhook-Timestamp | the unix seconds the signature was computed over |
Idempotency-Key | stable across every retry of the same delivery |
X-Correlation-Id | a 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
| status | body error | meaning |
|---|---|---|
| 200 | — | {"status":"accepted"} — the delivery was queued |
| 401 | WEBHOOK_UNAUTHORIZED | bad token, or a required signature that did not verify |
| 410 | WEBHOOK_DISABLED | the endpoint exists but is not accepting deliveries |
| 413 | PAYLOAD_TOO_LARGE | the body exceeds 1MB |
| 415 | UNSUPPORTED_MEDIA_TYPE | not Content-Type: application/json with a JSON body |
| 429 | RATE_LIMIT_EXCEEDED | more than 100 deliveries per minute to this receiver |
| 500 | INTERNAL_ERROR | our 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.