OnLink
Webhooks

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=onlink signs /hooks?src=onlink. Verify against your own framework's path — req.originalUrl in Express, new URL(req.url).pathname + search in plain Node. Do not reconstruct the absolute URL: behind a proxy you cannot know whether we saw your host as you.example or you.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

  1. Deduplicate on X-OnLink-Delivery. If you have seen it, return 2xx and stop. Retries reuse the id, so a repeat means your acknowledgement did not reach us — not that the event happened twice.
  2. Return 2xx immediately, then do your work asynchronously. The per-attempt timeout is 10 seconds.
  3. Treat the body's type as authoritative, not the X-OnLink-Event header.

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.

On this page