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

# Signature verification

> Verify HMAC-SHA256 signatures on incoming webhook deliveries

<blockquote className="agent-llms-directive">
  For the complete documentation index, see <a href="/llms.txt">llms.txt</a>.
</blockquote>

Every webhook delivery is signed with HMAC-SHA256 over a string built from a
timestamp, nonce, and the raw body. Always verify the signature AND reject
stale or replayed requests before processing events.

## Headers sent with every delivery

Read these headers from each webhook `POST` before parsing the JSON body. The
signature is the trust boundary; use `Content-Type` and `User-Agent` only for
handler routing and logs.

<CardGroup cols={2}>
  <Card title="Timestamp" icon="clock">
    `X-Xquik-Timestamp` is Unix epoch milliseconds. Reject requests outside the
    5-minute tolerance window.
  </Card>

  <Card title="Nonce" icon="key-round">
    `X-Xquik-Nonce` is 16 random bytes in hex. Store recent values for 5
    minutes and reject repeats.
  </Card>

  <Card title="Signature" icon="shield-check">
    `X-Xquik-Signature` is `sha256=<hex>` over
    `<timestamp>.<nonce>.<rawBody>` keyed with the endpoint secret.
  </Card>

  <Card title="Raw JSON body" icon="database">
    `Content-Type` is always `application/json`. Verify the raw body bytes
    before parsing or re-serializing JSON.
  </Card>

  <Card title="Sender logs" icon="radio">
    `User-Agent` is `xquik-webhooks/1.0 (+https://xquik.com)`. Log it for
    diagnostics, but never trust it instead of the signature.
  </Card>
</CardGroup>

## How it works

1. Xquik computes the signing string `<timestamp>.<nonce>.<rawBody>`.
2. Xquik computes `sha256=` + HMAC-SHA256(webhook secret, signing string).
3. Your server recomputes the signature with the same secret over the raw body.
4. Reject the request if the signature does not match in constant time.
5. Reject the request if the timestamp is older than 5 minutes (clock-skew tolerant).
6. Reject the request if the nonce was already seen in the last 5 minutes (replay protection).

## Receiver hardening handoff

Use this handoff when you promote a webhook receiver from a signed test
request to production monitor events. It keeps signing, replay protection,
idempotency, and incident routing visible in one review record.

```json theme={null}
{
  "workflow": "webhook_receiver_hardening",
  "signature": {
    "raw_body": true,
    "signing_string": "<timestamp>.<nonce>.<rawBody>",
    "headers": [
      "X-Xquik-Signature",
      "X-Xquik-Timestamp",
      "X-Xquik-Nonce"
    ],
    "replay_window_ms": 300000,
    "secret_logging": "never"
  },
  "nonce_store": {
    "key": "webhook_id:nonce",
    "ttl_seconds": 300,
    "on_duplicate": "reject"
  },
  "production_idempotency": {
    "delivery_id": "502",
    "stream_event_id": "9002",
    "test_payload_omits_ids": true
  },
  "incident_row": {
    "source": "GET /api/v1/webhooks/15/deliveries",
    "fields": [
      "status",
      "attempts",
      "lastStatusCode",
      "lastError",
      "createdAt",
      "deliveredAt"
    ]
  },
  "handoff_state": "verify_raw_body_store_nonce_then_dedupe_delivery"
}
```

<CardGroup cols={2}>
  <Card title="Raw body first" icon="braces">
    Keep raw request bytes available until signature verification succeeds.
    Parse JSON only after the HMAC check passes.
  </Card>

  <Card title="Nonce store" icon="database">
    Store each nonce with the webhook ID for 5 minutes. Reject duplicates before
    queueing or acknowledging the event.
  </Card>

  <Card title="Secret hygiene" icon="shield-check">
    Store the webhook secret once, use it only for HMAC checks, and never write
    it to logs, queues, traces, or incident rows.
  </Card>

  <Card title="Incident fields" icon="activity">
    Send `status`, `attempts`, `lastStatusCode`, `lastError`, `createdAt`, and
    `deliveredAt` from the deliveries API to your alerting or support queue.
  </Card>
</CardGroup>

## Implementation

<CodeGroup>
  ```bash cURL (test) theme={null}
  # Generate a test signature to verify your implementation
  TS=$(date +%s)000
  NONCE=$(openssl rand -hex 16)
  BODY='{"test":"payload"}'
  SIG=$(printf "%s.%s.%s" "$TS" "$NONCE" "$BODY" | openssl dgst -sha256 -hmac "your_webhook_secret" | sed 's/.*= /sha256=/')
  echo "X-Xquik-Timestamp: $TS"
  echo "X-Xquik-Nonce: $NONCE"
  echo "X-Xquik-Signature: $SIG"
  ```

  ```javascript Node.js theme={null}
  import { createHmac, timingSafeEqual } from "node:crypto";

  const FIVE_MINUTES_MS = 5 * 60 * 1000;
  const seenNonces = new Map(); // nonce -> expiresAt

  function verifyWebhook(req, secret) {
    const timestamp = req.headers["x-xquik-timestamp"];
    const nonce = req.headers["x-xquik-nonce"];
    const signature = req.headers["x-xquik-signature"];
    const rawBody = req.body.toString();

    if (!timestamp || !nonce || !signature) return false;

    // Reject stale requests (clock-skew tolerant).
    const ts = Number(timestamp);
    if (!Number.isFinite(ts) || Math.abs(Date.now() - ts) > FIVE_MINUTES_MS) {
      return false;
    }

    // Reject replays.
    const now = Date.now();
    for (const [n, exp] of seenNonces) if (exp <= now) seenNonces.delete(n);
    if (seenNonces.has(nonce)) return false;
    seenNonces.set(nonce, now + FIVE_MINUTES_MS);

    const signingString = `${timestamp}.${nonce}.${rawBody}`;
    const expected =
      "sha256=" + createHmac("sha256", secret).update(signingString).digest("hex");
    const expectedBuffer = Buffer.from(expected);
    const signatureBuffer = Buffer.from(signature);
    if (expectedBuffer.length !== signatureBuffer.length) return false;
    return timingSafeEqual(expectedBuffer, signatureBuffer);
  }

  // Express example
  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    if (!verifyWebhook(req, WEBHOOK_SECRET)) {
      return res.status(401).send("Invalid signature");
    }
    const event = JSON.parse(req.body.toString());
    // Process event...
    res.status(200).send("OK");
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  from flask import Flask, request

  app = Flask(__name__)
  FIVE_MINUTES_MS = 5 * 60 * 1000
  _seen_nonces: dict[str, int] = {}

  def verify_webhook(req, secret: str) -> bool:
      timestamp = req.headers.get("X-Xquik-Timestamp", "")
      nonce = req.headers.get("X-Xquik-Nonce", "")
      signature = req.headers.get("X-Xquik-Signature", "")
      raw_body = req.get_data()

      if not (timestamp and nonce and signature):
          return False

      try:
          ts = int(timestamp)
      except ValueError:
          return False
      now_ms = int(time.time() * 1000)
      if abs(now_ms - ts) > FIVE_MINUTES_MS:
          return False

      expired = [n for n, exp in _seen_nonces.items() if exp <= now_ms]
      for n in expired:
          _seen_nonces.pop(n, None)
      if nonce in _seen_nonces:
          return False
      _seen_nonces[nonce] = now_ms + FIVE_MINUTES_MS

      signing_string = f"{timestamp}.{nonce}.".encode() + raw_body
      expected = "sha256=" + hmac.new(
          secret.encode(), signing_string, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(expected, signature)

  @app.route("/webhook", methods=["POST"])
  def webhook():
      if not verify_webhook(request, WEBHOOK_SECRET):
          return "Invalid signature", 401
      event = request.get_json()
      # Process event...
      return "OK", 200
  ```

  ```go Go theme={null}
  package main

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

  const fiveMinutesMs = int64(5 * 60 * 1000)

  var (
  	seenNonces = sync.Map{}
  )

  func verifyWebhook(payload []byte, headers http.Header, secret string) bool {
  	timestamp := headers.Get("X-Xquik-Timestamp")
  	nonce := headers.Get("X-Xquik-Nonce")
  	signature := headers.Get("X-Xquik-Signature")
  	if timestamp == "" || nonce == "" || signature == "" {
  		return false
  	}

  	ts, err := strconv.ParseInt(timestamp, 10, 64)
  	if err != nil {
  		return false
  	}
  	nowMs := time.Now().UnixMilli()
  	diff := nowMs - ts
  	if diff < 0 {
  		diff = -diff
  	}
  	if diff > fiveMinutesMs {
  		return false
  	}

  	seenNonces.Range(func(key, value any) bool {
  		expiresAt, ok := value.(int64)
  		if ok && expiresAt <= nowMs {
  			seenNonces.Delete(key)
  		}
  		return true
  	})
  	if _, replayed := seenNonces.LoadOrStore(nonce, nowMs+fiveMinutesMs); replayed {
  		return false
  	}

  	signingString := fmt.Sprintf("%s.%s.%s", timestamp, nonce, string(payload))
  	mac := hmac.New(sha256.New, []byte(secret))
  	mac.Write([]byte(signingString))
  	expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
  	return hmac.Equal([]byte(expected), []byte(signature))
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
  	payload, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "Bad request", http.StatusBadRequest)
  		return
  	}
  	if !verifyWebhook(payload, r.Header, webhookSecret) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}
  	// Process event...
  	fmt.Fprint(w, "OK")
  }
  ```
</CodeGroup>

## Security checklist

<AccordionGroup>
  <Accordion title="Always verify before processing">
    Never process webhook payloads without verifying the signature first. An unverified payload could be a spoofed request.
  </Accordion>

  <Accordion title="Use constant-time comparison">
    Use `timingSafeEqual` (Node.js), `hmac.compare_digest` (Python), or `hmac.Equal` (Go). String equality (`===`) is vulnerable to timing attacks.
  </Accordion>

  <Accordion title="Use the raw request body">
    Compute the HMAC over the raw request body bytes, not a re-serialized JSON object. Re-serialization can alter whitespace or key ordering.
  </Accordion>

  <Accordion title="Respond quickly">
    We recommend responding within 10 seconds. Process events asynchronously if your handler is slow.
  </Accordion>
</AccordionGroup>

## Idempotency

Webhook deliveries can be retried on failure, so your endpoint may receive the same event multiple times. Production monitor deliveries include `deliveryId` and `streamEventId`. Use `deliveryId` as the webhook delivery idempotency key. Use `streamEventId` when your system should process one monitor event only once across webhook retries or endpoint changes. Do not hash the raw request body when `deliveryId` is available.

`webhook.test` deliveries include `eventType`, `data`, and `timestamp`; they omit monitor idempotency fields. Use them to verify signatures and receiver reachability, then skip production event de-dupe for the test request.

### Production store contract

Use a persistent store before acknowledging production events. Scope nonce,
delivery, and event keys by webhook ID so retries for one endpoint never block
another endpoint. Duplicate `deliveryId` or `streamEventId` checks should
return `2xx` after verification, because the receiver has already accepted the
work.

```json theme={null}
{
  "record_type": "webhook_receiver_store_contract",
  "webhook_id": "15",
  "nonce_key": "webhook:15:nonce_hash",
  "nonce_ttl_seconds": 300,
  "delivery_key": "webhook:15:delivery:502",
  "event_key": "event:9002",
  "duplicate_delivery_status": 200,
  "duplicate_event_status": 200,
  "event_join": "GET /api/v1/events/9002",
  "ack_after": "signature_verified_and_queue_row_written",
  "ack_status": 202,
  "slow_work": "async_worker",
  "delivery_log": "GET /api/v1/webhooks/15/deliveries",
  "shared_storage_excludes": [
    "endpoint_signing_values",
    "raw_request_body",
    "raw_signature",
    "full_headers"
  ]
}
```

Keep raw request bytes only for signature verification. Store the nonce cache
marker, delivery key, event key, join route, and processing status in the
shared table or queue. Return `2xx` only after verification and a durable queue
write. Move slow enrichment, exports, CRM sync, or alerting to an async worker
so the receiver can finish within the 10-second delivery timeout.

<CodeGroup>
  ```javascript Node.js theme={null}
  const processedDeliveries = new Set();
  const processedEvents = new Set();

  app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
    const payload = req.body.toString();

    if (!verifyWebhook(req, WEBHOOK_SECRET)) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(payload);

    if (event.eventType === "webhook.test") {
      return res.status(200).send("Test accepted");
    }

    if (
      typeof event.deliveryId !== "string" ||
      typeof event.streamEventId !== "string"
    ) {
      return res.status(400).send("Missing idempotency fields");
    }

    if (processedDeliveries.has(event.deliveryId)) {
      return res.status(200).send("Delivery already processed");
    }

    processedDeliveries.add(event.deliveryId);

    if (processedEvents.has(event.streamEventId)) {
      return res.status(200).send("Event already processed");
    }

    processedEvents.add(event.streamEventId);
    handleEvent(event);

    res.status(200).send("OK");
  });
  ```

  ```python Python theme={null}
  processed_deliveries = set()
  processed_events = set()

  @app.route("/webhook", methods=["POST"])
  def webhook():
      payload = request.get_data()

      if not verify_webhook(request, WEBHOOK_SECRET):
          return "Invalid signature", 401

      event = request.get_json()

      if event.get("eventType") == "webhook.test":
          return "Test accepted", 200

      delivery_id = event.get("deliveryId")
      stream_event_id = event.get("streamEventId")
      if not isinstance(delivery_id, str) or not isinstance(stream_event_id, str):
          return "Missing idempotency fields", 400

      if delivery_id in processed_deliveries:
          return "Delivery already processed", 200

      processed_deliveries.add(delivery_id)

      if stream_event_id in processed_events:
          return "Event already processed", 200

      processed_events.add(stream_event_id)

      handle_event(event)

      return "OK", 200
  ```

  ```go Go theme={null}
  type WebhookEvent struct {
  	Data          map[string]any `json:"data"`
  	DeliveryID    string         `json:"deliveryId"`
  	EventType     string         `json:"eventType"`
  	StreamEventID string         `json:"streamEventId"`
  }

  var (
  	processedDeliveries sync.Map
  	processedEvents     sync.Map
  )

  func processSignedWebhook(w http.ResponseWriter, r *http.Request) {
  	payload, err := io.ReadAll(r.Body)
  	if err != nil {
  		http.Error(w, "Bad request", http.StatusBadRequest)
  		return
  	}

  	if !verifyWebhook(payload, r.Header, webhookSecret) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}

  	var event WebhookEvent
  	if err := json.Unmarshal(payload, &event); err != nil {
  		http.Error(w, "Bad request", http.StatusBadRequest)
  		return
  	}

  	if event.EventType == "webhook.test" {
  		fmt.Fprint(w, "Test accepted")
  		return
  	}

  	if event.DeliveryID == "" || event.StreamEventID == "" {
  		http.Error(w, "Missing idempotency fields", http.StatusBadRequest)
  		return
  	}

  	if _, replayed := processedDeliveries.LoadOrStore(
  		event.DeliveryID,
  		true,
  	); replayed {
  		fmt.Fprint(w, "Delivery already processed")
  		return
  	}

  	if _, seen := processedEvents.LoadOrStore(
  		event.StreamEventID,
  		true,
  	); seen {
  		fmt.Fprint(w, "Event already processed")
  		return
  	}

  	handleEvent(event)
  	fmt.Fprint(w, "OK")
  }
  ```
</CodeGroup>

<Warning>
  The in-memory examples above work for single-process servers. In production, use a persistent store (database, Redis) to track processed delivery IDs and event IDs across restarts and multiple instances.
</Warning>
