Skip to main content
Anchor signs every delivery with HMAC-SHA256 over v0:{timestamp}:{raw_body} using your webhook’s signing secret. The signature lives in the Anchor-Signature header:
Anchor-Signature: t=1716544084,v1=<hex hmac sha256>
To verify a request:
  1. Read the Anchor-Timestamp and Anchor-Signature headers, plus the raw request body bytes (do not parse the JSON first - re-serializing changes whitespace and breaks the signature).
  2. Reject the request if the timestamp is more than 2 minutes old (replay protection).
  3. Compute HMAC_SHA256(secret, "v0:" + timestamp + ":" + raw_body).hex().
  4. Compare it against the v1= value in Anchor-Signature using a constant-time compare (hmac.compare_digest, crypto.timingSafeEqual, etc.).
  5. Only accept the event if the signatures match.
Always compute the HMAC over the raw request body bytes. If your framework parses JSON before you read the body (e.g. body-parser, FastAPI’s Body(...), gin’s BindJSON), you must opt out of that for the webhook route - re-serializing the parsed object will produce different bytes and the signature will fail.

Code examples

Each example below performs four checks in order: presence of the headers, freshness (timestamp within 2 minutes of the time you received the request), HMAC signature, and finally a duplicate-id check so a retry doesn’t re-trigger your business logic.
The dedupe maps below are kept in-process for clarity. In production you should swap them for a persistent store with a TTL of at least your retry budget (24 hours is safe) — Redis, DynamoDB, or a seen_event_ids (id PRIMARY KEY, seen_at) table all work. An in-memory map won’t survive a restart and won’t dedupe across replicas behind a load balancer.
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.ANCHOR_WEBHOOK_SECRET;
const REPLAY_WINDOW_SECONDS = 2 * 60; // 2 minutes — reject anything older

// ─── Idempotency store (in-memory; swap for Redis/DB in production) ──
// Maps event id (e.g. evt_01HXJ4...) -> expiry timestamp in ms.
// We keep entries for at least the retry budget so a late retry is still
// recognised as a duplicate. 24 h covers Anchor's worst-case retry window.
const DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
const seenEventIds = new Map();
setInterval(() => {
  const now = Date.now();
  for (const [id, expiresAt] of seenEventIds) {
    if (expiresAt < now) seenEventIds.delete(id);
  }
}, 60_000).unref();

// IMPORTANT: capture the raw bytes BEFORE JSON parsing.
app.post(
  '/anchor/webhooks',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signatureHeader = req.header('Anchor-Signature') || '';
    const timestamp = req.header('Anchor-Timestamp') || '';
    if (!signatureHeader || !timestamp) return res.status(400).send('missing signature');

    // 1. Freshness / replay protection (compare the request's `Anchor-Timestamp`
    //    against the local clock at the moment the request arrived).
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (Number.isNaN(Number(timestamp)) || ageSeconds > REPLAY_WINDOW_SECONDS) {
      return res.status(400).send('stale request');
    }

    // 2. Parse the v1=<hex> value out of "t=...,v1=..."
    const parts = Object.fromEntries(
      signatureHeader.split(',').map((p) => p.split('=')),
    );
    const provided = parts.v1;
    if (!provided) return res.status(400).send('missing v1 signature');

    // 3. Constant-time HMAC compare
    const baseString = `v0:${timestamp}:${req.body.toString('utf8')}`;
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(baseString)
      .digest('hex');
    if (
      expected.length !== provided.length ||
      !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
    ) {
      return res.status(401).send('invalid signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // 4. Idempotency — short-circuit duplicates with 200 so Anchor stops retrying.
    if (seenEventIds.has(event.id)) {
      console.log('Duplicate event, acknowledging without re-processing:', event.id);
      return res.status(200).send('ok');
    }
    seenEventIds.set(event.id, Date.now() + DEDUPE_TTL_MS);

    console.log('Verified event:', event.type, event.id);
    // ... business logic here ...
    res.status(200).send('ok');
  },
);

app.listen(3000);

Rotating your signing secret

Click Rotate signing secret in the dashboard (or call POST /v1/webhooks/{id}/rotate-secret) to issue a new secret. The previous secret remains valid for 24 hours so you can roll your verification code without dropping events:
  1. Receive the new secret from the rotate-secret response. Anchor returns it once.
  2. Update your verifier so it accepts deliveries signed by either secret during the overlap window.
  3. Deploy.
  4. After 24 hours, remove the old secret from your verifier - Anchor stops accepting it automatically.

Common verification pitfalls

  • Re-serialized body. Frameworks like Express’s body-parser or FastAPI’s Body(...) decode the JSON before you see it. Capture the raw bytes explicitly (express.raw, request.get_data(), io.ReadAll(r.Body)).
  • Wrong timestamp source. Use the t= value from Anchor-Signature (equivalent to the Anchor-Timestamp header). Do not use the time you received the request - clock skew between your machine and Anchor will break the HMAC base string.
  • String comparison. Use a constant-time compare (hmac.compare_digest, crypto.timingSafeEqual, hmac.Equal) to avoid timing-side-channel attacks.
  • Missing replay check. Reject anything older than 2 minutes.