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

# Verify signatures

> Check the signature on every delivery — code included for Node.js, Python, and PHP.

Every webhook delivery is signed with your endpoint secret (`whsec_...`) using HMAC-SHA256. Verify the signature before trusting any payload — an unverified webhook could be forged by anyone who knows your URL.

## Header format

```text theme={null}
X-Wizzgift-Signature: t=1753142400000,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

* `t` — delivery timestamp in **milliseconds**.
* `v1` — hex HMAC-SHA256 of the string `"<t>.<raw body>"` keyed with your secret.
* During the 24 hours after a [secret rotation](#rotating-secrets), the header carries **two** `v1` entries — one per secret. A match on either is valid.

## Verification steps

1. Read the **raw** request body, before any JSON parsing or re-serialization.
2. Parse the header: extract `t` and every `v1` value.
3. Compute `HMAC-SHA256(secret, "<t>.<rawBody>")` as lowercase hex.
4. Compare against each `v1` using a constant-time comparison. Any match passes.
5. Reject if the timestamp is stale — 5 minutes tolerance is a good default.

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require("node:crypto");

  function verifyWizzgiftSignature(header, rawBody, secret, toleranceMs = 300_000) {
    const parts = header.split(",").map((p) => p.split("="));
    const t = Number(parts.find(([k]) => k === "t")?.[1]);
    const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
    if (!t || Math.abs(Date.now() - t) > toleranceMs) return false;

    const expected = crypto
      .createHmac("sha256", secret)
      .update(`${t}.${rawBody}`)
      .digest("hex");

    return sigs.some((sig) => {
      try {
        return crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));
      } catch {
        return false;
      }
    });
  }

  // Express example — note express.raw, NOT express.json
  app.post("/wizzgift/webhook", express.raw({ type: "application/json" }), (req, res) => {
    const ok = verifyWizzgiftSignature(
      req.header("X-Wizzgift-Signature"),
      req.body.toString("utf8"),
      process.env.WIZZGIFT_WEBHOOK_SECRET,
    );
    if (!ok) return res.status(400).send("invalid signature");

    res.sendStatus(200); // acknowledge fast, process async
    const event = JSON.parse(req.body);
    queue.push(event);
  });
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_wizzgift_signature(header: str, raw_body: bytes, secret: str,
                                tolerance_ms: int = 300_000) -> bool:
      parts = [p.split("=", 1) for p in header.split(",")]
      t = next((v for k, v in parts if k == "t"), None)
      sigs = [v for k, v in parts if k == "v1"]
      if t is None or abs(time.time() * 1000 - int(t)) > tolerance_ms:
          return False

      expected = hmac.new(
          secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()

      return any(hmac.compare_digest(sig, expected) for sig in sigs)


  # Flask example — use request.get_data(), not request.json
  @app.post("/wizzgift/webhook")
  def wizzgift_webhook():
      if not verify_wizzgift_signature(
          request.headers.get("X-Wizzgift-Signature", ""),
          request.get_data(),
          WIZZGIFT_WEBHOOK_SECRET,
      ):
          return "invalid signature", 400
      process_async(request.get_json())
      return "", 200
  ```

  ```php PHP theme={null}
  function verifyWizzgiftSignature(
      string $header,
      string $rawBody,
      string $secret,
      int $toleranceMs = 300000
  ): bool {
      $t = null;
      $sigs = [];
      foreach (explode(',', $header) as $part) {
          [$k, $v] = explode('=', $part, 2);
          if ($k === 't') $t = (int) $v;
          if ($k === 'v1') $sigs[] = $v;
      }
      if ($t === null || abs((int) (microtime(true) * 1000) - $t) > $toleranceMs) {
          return false;
      }

      $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
      foreach ($sigs as $sig) {
          if (hash_equals($expected, $sig)) return true;
      }
      return false;
  }

  // Usage
  $rawBody = file_get_contents('php://input');
  $ok = verifyWizzgiftSignature(
      $_SERVER['HTTP_X_WIZZGIFT_SIGNATURE'] ?? '',
      $rawBody,
      getenv('WIZZGIFT_WEBHOOK_SECRET')
  );
  if (!$ok) {
      http_response_code(400);
      exit;
  }
  ```
</CodeGroup>

<Warning>
  The HMAC is computed over the **raw bytes** we sent. If your framework parses JSON before you can read the body, the re-serialized string will not match — configure a raw-body route for the webhook path (see the framework notes in each example).
</Warning>

## Rotating secrets

```bash theme={null}
curl -X POST https://api.wizzgift.com/retailer/v1/webhook/rotate \
  -H "X-API-Key: wg_live_..."
```

```json Response theme={null}
{ "secret": "whsec_kJ8s...", "previousSecretValidForMs": 86400000 }
```

Rotation is zero-downtime: for 24 hours, deliveries are signed with **both** secrets (two `v1` entries), so you can deploy the new secret at your own pace. After the window, only the new secret signs.

Test the full path any time with `POST /retailer/v1/webhook/test` — it sends a real signed `ping` synchronously and reports your endpoint's response code.

## Checklist

* [ ] Verify on the raw body, before JSON parsing
* [ ] Constant-time comparison (`timingSafeEqual`, `hmac.compare_digest`, `hash_equals`)
* [ ] Accept any matching `v1` entry (rotation window has two)
* [ ] Enforce a timestamp tolerance (about 5 minutes)
* [ ] Deduplicate on the delivery id (`X-Wizzgift-Delivery`)
* [ ] Respond `2xx` fast; process asynchronously
