Verifying signatures
Anyone who learns your endpoint's URL can post to it. The signature on each delivery is how you know a request came from Esfera and that its body was not altered on the way. Verify it before you act on anything in the body. This page gives the exact scheme, working code in the Node SDK, plain Node and Python, and a known-answer vector so you can test your implementation before you go live.
The scheme
Esfera signs every delivery with the endpoint's secret and puts the result in the X-Esfera-Signature header:
X-Esfera-Signature: t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7tis the Unix time in seconds at which that attempt was signed.v1is the lowercase hexadecimal HMAC-SHA256 of the string<t>.<raw body>, a decimal timestamp, a full stop, then the body exactly as it was sent. The key is the endpoint's secret, the wholewhsec_esf_…string including its prefix, as UTF-8 bytes.- The scheme has the same shape as the one many payment providers use, so existing verification code is easy to adapt. The header name differs and there is only one
v1value.
To verify a delivery:
- Read the raw request body as bytes. Do not parse and re-serialise it: a different key order or spacing gives a different HMAC.
- Split the header into
tandv1. If either is missing orv1is not 64 hex characters, reject. - Reject if the absolute difference between now and
tis more than 300 seconds. This stops an old capture being replayed at you. - Compute HMAC-SHA256 of
"<t>.<raw body>"with your secret and compare it withv1in constant time. - Only then parse the JSON.
Every attempt is signed afresh, with a new t and so a new v1, even when it re-sends the same body. A retry that lands hours after the first attempt still passes the 300-second check, because the timestamp is the time of that attempt. A replay is signed afresh too.
There is no secret rotation. To change a secret, register a new endpoint (an account may have up to three active), switch over, and revoke the old one.
With the Node SDK
Esfera.webhooks.constructEvent verifies the signature, checks the timestamp and parses the body in one call. It throws SignatureVerificationError when anything is wrong. It needs no API key, and it runs on Node, Bun, Deno and Workers.
import express from "express";
import { Esfera, SignatureVerificationError } from "@esfera/sdk";
const app = express();
// The raw body is required: use express.raw, not express.json, on this route.
app.post("/esfera", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = await Esfera.webhooks.constructEvent(
req.body, // a Buffer: the raw bytes
req.header("X-Esfera-Signature") ?? "",
process.env.ESFERA_WEBHOOK_SECRET!, // whsec_esf_…
);
// Store the event, then answer. Do the work after responding.
console.log(event.event, event.data.conversion.id, event.data.conversion.status);
res.sendStatus(200);
} catch (err) {
if (err instanceof SignatureVerificationError) return res.sendStatus(400);
throw err;
}
});Esfera.webhooks.verify(rawBody, header, secret) is the same check without the parsing. It returns true or false and never throws. Both accept { toleranceSec, now } as a fourth argument. The default tolerance is 300 seconds.
With plain Node
No SDK needed. This is the same algorithm with node:crypto:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(secret, header, rawBody, { toleranceSec = 300, nowMs = Date.now() } = {}) {
const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
if (!m || !secret) return false;
const t = Number(m[1]);
if (Math.abs(nowMs / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
return timingSafeEqual(expected, Buffer.from(m[2], "hex"));
}rawBody is a Buffer or a string, whichever your framework gives you before it parses.
With Python
import hashlib
import hmac
import re
import time
_HEADER = re.compile(r"^t=(\d+),v1=([0-9a-f]{64})$")
def verify(secret: str, header: str, raw_body: bytes, tolerance_sec: int = 300, now: float | None = None) -> bool:
m = _HEADER.match(header or "")
if not m or not secret:
return False
t = int(m.group(1))
if abs((time.time() if now is None else now) - t) > tolerance_sec:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m.group(2))In Flask, pass request.get_data(). In Django, request.body. In FastAPI, await request.body(). Take the bytes before any JSON parsing.
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/esfera")
def esfera():
if not verify(SECRET, request.headers.get("X-Esfera-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
# Store the event, then answer.
return "", 200A known-answer vector
Use this to check your implementation before you trust it. It does not use a real endpoint secret.
| Input | Value |
|---|---|
| Secret | known-answer-secret |
t | 1756200000 |
| Raw body | {"a":1} (seven bytes, no trailing newline) |
| Signed string | 1756200000.{"a":1} |
| Header | t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7 |
The vector comes from Esfera's own test suite, where it is checked against an independent HMAC implementation. Your code must produce that v1 from the first three rows. You can check it from a shell:
printf '%s' '1756200000.{"a":1}' | openssl dgst -sha256 -hmac 'known-answer-secret'
# prints the digest: ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7The timestamp is long past, so a verifier that checks tolerance will refuse the header in real time. To test the full function, pass a clock that matches t:
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":1}', { nowMs: 1756200000_000 }); // true
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":2}', { nowMs: 1756200000_000 }); // false: body changed
verify("known-answer-secret", "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7", '{"a":1}', { nowMs: 1756200301_000 }); // false: 301 s oldH = "t=1756200000,v1=ab07d307bf84e71631e80b57851f4b05c40a1e33ea3c2498b4722906de611bc7"
assert verify("known-answer-secret", H, b'{"a":1}', now=1756200000)
assert not verify("known-answer-secret", H, b'{"a":2}', now=1756200000)
assert not verify("known-answer-secret", H, b'{"a":1}', now=1756200301)With the SDK: await Esfera.webhooks.verify('{"a":1}', H, "known-answer-secret", { now: 1756200000_000 }) returns true.
Mistakes that break verification
- Verifying a parsed-and-re-serialised body. Use the raw bytes.
- Using
express.json()before the route. It consumes the body. Useexpress.rawon the webhook route. - Trimming or normalising the body, including a trailing newline.
- Comparing hex strings with
==. Use a constant-time comparison. - Using the endpoint's
idor only the part of the secret afterwhsec_esf_. The key is the entire secret string. - A server clock that is more than five minutes out. Keep it on NTP.