Verifying signatures
Every webhook carries an HMAC-SHA256 signature over five newline-joined fields. Verify it before you trust the body.
Anyone can POST JSON at your endpoint. The signature is what tells you a
delivery came from us, so verify it before you read the body — and reject the
delivery if it does not match, rather than logging and continuing.
The string to sign
Five fields, joined with \n, in this exact order, with no trailing newline:
POST
<path and query of your webhook URL>
<unix milliseconds>
<delivery id>
<lowercase hex sha256 of the exact request body bytes>The signature is HMAC-SHA256(your signing secret, that string), lowercase hex,
sent as X-OnLink-Signature: v1=<hex>.
Field by field:
POST— the method, upper-cased. It is in the signature so a captured signature cannot be presented as some other method.- The path and query, not the whole URL. A webhook URL of
https://you.example/hooks?src=onlinksigns/hooks?src=onlink. Verify against your own framework's path —req.originalUrlin Express,new URL(req.url).pathname + searchin plain Node. Do not reconstruct the absolute URL: behind a proxy you cannot know whether we saw your host asyou.exampleoryou.example:443, and a mismatch there is unverifiable. - The timestamp, unix milliseconds, from
X-OnLink-Timestamp. - The delivery id, from
X-OnLink-Delivery. It is stable across retries, so the same value gives you replay protection and idempotency at once. sha256(body), lowercase hex, over the raw bytes you received. Hash the body before any JSON parse-and-re-serialise: re-serialising changes key order and whitespace, and the hash will not match.
Hash the raw body, not the parsed object
This is the mistake that costs the most time. Most frameworks hand you a parsed
object and discard the bytes. Configure your route to keep the raw body — in
Express, express.raw({ type: 'application/json' }) on the webhook route, or
the verify callback of express.json().
Worked example
const crypto = require('node:crypto');
function verifyOnLinkWebhook({ secret, path, headers, rawBody }) {
const received = headers['x-onlink-signature'];
const timestamp = headers['x-onlink-timestamp'];
const deliveryId = headers['x-onlink-delivery'];
if (!received || !timestamp || !deliveryId) return false;
// Reject anything far from your own clock, so a captured delivery cannot be
// replayed days later. Five minutes matches the window we enforce inbound.
if (Math.abs(Date.now() - Number(timestamp)) > 300_000) return false;
const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex');
const signingString = ['POST', path, timestamp, deliveryId, bodyHash].join(
'\n',
);
const expected =
'v1=' +
crypto.createHmac('sha256', secret).update(signingString).digest('hex');
// Constant-time compare. `===` leaks how much of the signature matched, which
// is enough to forge one byte at a time given enough attempts.
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}After the signature checks out
- Deduplicate on
X-OnLink-Delivery. If you have seen it, return2xxand stop. Retries reuse the id, so a repeat means your acknowledgement did not reach us — not that the event happened twice. - Return
2xximmediately, then do your work asynchronously. The per-attempt timeout is 10 seconds. - Treat the body's
typeas authoritative, not theX-OnLink-Eventheader.
If verification keeps failing
Almost every failure is one of four things, in order of likelihood:
- the body was re-serialised before hashing (see the callout above);
- the absolute URL was signed instead of the path and query;
- the timestamp was read as seconds — it is milliseconds;
- the wrong secret. Your webhook signing secret is not your API secret.
Log the signing string you built while you debug, and compare it field by field against the five above. Do not log the secret or the signature.