> ## Documentation Index
> Fetch the complete documentation index at: https://docs.anchorbrowser.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Signature verification

> Verify that webhook requests really came from Anchor using HMAC-SHA256 and timestamp-based replay protection.

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.

<Warning>
  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.
</Warning>

## 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.

<Note>
  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.
</Note>

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  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);
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import os
  import threading
  import time
  from flask import Flask, abort, request

  app = Flask(__name__)
  SECRET = os.environ["ANCHOR_WEBHOOK_SECRET"].encode()
  REPLAY_WINDOW_SECONDS = 2 * 60  # 2 minutes — reject anything older

  # ─── Idempotency store (in-memory; swap for Redis/DB in production) ──
  DEDUPE_TTL_SECONDS = 24 * 60 * 60
  _seen_event_ids: dict[str, float] = {}  # id -> expiry epoch seconds
  _seen_lock = threading.Lock()


  def _is_duplicate(event_id: str) -> bool:
      now = time.time()
      with _seen_lock:
          # Opportunistic prune so the map doesn't grow indefinitely.
          for k, exp in list(_seen_event_ids.items()):
              if exp < now:
                  _seen_event_ids.pop(k, None)
          if event_id in _seen_event_ids:
              return True
          _seen_event_ids[event_id] = now + DEDUPE_TTL_SECONDS
          return False


  @app.post("/anchor/webhooks")
  def handle_webhook():
      signature_header = request.headers.get("Anchor-Signature", "")
      timestamp = request.headers.get("Anchor-Timestamp", "")
      if not signature_header or not timestamp:
          abort(400, "missing signature")

      # 1. Freshness / replay protection
      try:
          ts = int(timestamp)
      except ValueError:
          abort(400, "bad timestamp")
      if abs(time.time() - ts) > REPLAY_WINDOW_SECONDS:
          abort(400, "stale request")

      parts = dict(p.split("=", 1) for p in signature_header.split(","))
      provided = parts.get("v1", "")

      # 2. HMAC compare (raw bytes — do NOT call request.get_json() first).
      raw = request.get_data()
      base_string = f"v0:{timestamp}:".encode() + raw
      expected = hmac.new(SECRET, base_string, hashlib.sha256).hexdigest()
      if not hmac.compare_digest(expected, provided):
          abort(401, "invalid signature")

      event = request.get_json()

      # 3. Idempotency — short-circuit duplicates with 200.
      if _is_duplicate(event["id"]):
          print(f"Duplicate event, acknowledging without re-processing: {event['id']}")
          return ("ok", 200)

      print(f"Verified event: {event['type']} {event['id']}")
      # ... business logic here ...
      return ("ok", 200)
  ```

  ```go Go (net/http) theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "io"
      "net/http"
      "os"
      "strconv"
      "strings"
      "sync"
      "time"
  )

  var secret = []byte(os.Getenv("ANCHOR_WEBHOOK_SECRET"))

  const (
      replayWindowSeconds = 2 * 60         // 2 minutes — reject anything older
      dedupeTTLSeconds    = 24 * 60 * 60   // remember an event id for the full retry budget
  )

  // In-memory idempotency store. Swap for Redis/DB in production.
  var (
      seenMu  sync.Mutex
      seenIDs = make(map[string]int64) // id -> expiry epoch seconds
  )

  func isDuplicate(eventID string) bool {
      now := time.Now().Unix()
      seenMu.Lock()
      defer seenMu.Unlock()
      // Opportunistic prune.
      for id, exp := range seenIDs {
          if exp < now {
              delete(seenIDs, id)
          }
      }
      if _, ok := seenIDs[eventID]; ok {
          return true
      }
      seenIDs[eventID] = now + dedupeTTLSeconds
      return false
  }

  func handle(w http.ResponseWriter, r *http.Request) {
      timestamp := r.Header.Get("Anchor-Timestamp")
      sigHeader := r.Header.Get("Anchor-Signature")
      if timestamp == "" || sigHeader == "" {
          http.Error(w, "missing signature", http.StatusBadRequest)
          return
      }

      // 1. Freshness / replay protection
      ts, err := strconv.ParseInt(timestamp, 10, 64)
      if err != nil || abs(time.Now().Unix()-ts) > replayWindowSeconds {
          http.Error(w, "stale request", http.StatusBadRequest)
          return
      }

      var provided string
      for _, p := range strings.Split(sigHeader, ",") {
          if strings.HasPrefix(p, "v1=") {
              provided = strings.TrimPrefix(p, "v1=")
          }
      }

      body, _ := io.ReadAll(r.Body)

      // 2. HMAC compare
      mac := hmac.New(sha256.New, secret)
      mac.Write([]byte("v0:" + timestamp + ":"))
      mac.Write(body)
      expected := hex.EncodeToString(mac.Sum(nil))
      if !hmac.Equal([]byte(expected), []byte(provided)) {
          http.Error(w, "invalid signature", http.StatusUnauthorized)
          return
      }

      // 3. Idempotency — short-circuit duplicates with 200.
      var event struct {
          ID   string `json:"id"`
          Type string `json:"type"`
      }
      if err := json.Unmarshal(body, &event); err != nil {
          http.Error(w, "bad json", http.StatusBadRequest)
          return
      }
      if isDuplicate(event.ID) {
          // Anchor counts 200 as delivered and stops retrying.
          w.WriteHeader(http.StatusOK)
          return
      }

      // ... business logic here, then ack ...
      w.WriteHeader(http.StatusOK)
  }

  func abs(x int64) int64 { if x < 0 { return -x }; return x }
  ```
</CodeGroup>

## 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.
