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

# Security

> Verify webhook signatures to ensure requests are from Zavu

Zavu signs every webhook with HMAC-SHA256. Verify the signature before you
trust a payload.

## Signature Header

Every request carries `X-Zavu-Signature`:

```
X-Zavu-Signature: t=1786113454,v2=b4b2b61cdcdb241e4270298a08c3c13c964642df...
```

| Part | Meaning                                                         |
| ---- | --------------------------------------------------------------- |
| `t`  | Unix timestamp in **seconds**, when the signature was generated |
| `v1` | `HMAC_SHA256(secret, body)`                                     |
| `v2` | `HMAC_SHA256(secret, "{t}.{body}")`                             |

A header carries `v1`, `v2`, or both. Which one you get is per receiver, and
you control it.

## The two schemes

**`v2` is the current scheme**, and it is what new webhooks use. It signs the
timestamp together with the body, so more of the request is covered by the
signature.

**`v1` signs the body only.** Webhooks created before the scheme was
configurable are on `v1` and stay there until you move them. There is no
deadline.

Moving takes three steps and no downtime. See
[Migrating to v2 signatures](/guides/receiving-messages/signature-migration).

<Note>
  Older versions of this guide described verifying `v1` by hashing
  `{timestamp}.{body}`. That was wrong: `v1` covers the body alone, so a receiver
  built that way rejects every delivery. If that is what you have, either fix the
  computation or move the sender to `v2`, where hashing `{timestamp}.{body}` is
  correct.
</Note>

## Which scheme is my webhook on?

```sh theme={null}
curl https://api.zavu.dev/v1/senders/$SENDER_ID \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY"
```

```json theme={null}
{
  "id": "sndr_...",
  "webhook": {
    "url": "https://api.example.com/webhooks/zavu",
    "events": ["message.inbound"],
    "active": true,
    "signatureVersion": "v1"
  }
}
```

## Verifying

Four steps, in this order.

### 1. Read the raw body

The signature covers the exact bytes that were sent. A parsed and re-serialized
object is not those bytes.

```javascript theme={null}
// Express: mount a raw parser on this route, not express.json()
app.use('/webhooks/zavu', express.raw({ type: 'application/json' }));
```

### 2. Parse the header

```javascript theme={null}
function parseSignature(header) {
  const parts = {};
  for (const piece of header.split(',')) {
    const i = piece.indexOf('=');
    if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1);
  }
  return parts; // { t, v1?, v2? }
}
```

Ignore parts you do not recognize, so a future scheme does not break your
parser.

### 3. Check the timestamp

```javascript theme={null}
const age = Math.floor(Date.now() / 1000) - Number(parts.t);
if (age > 300) return false;   // older than five minutes
if (age < -60) return false;   // clock skew, or forged
```

### 4. Recompute and compare

Use the `t` **from the header**, not your own clock.

```javascript theme={null}
const expected = parts.v2
  ? crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
  : crypto.createHmac('sha256', secret).update(rawBody).digest('hex');

const received = parts.v2 ?? parts.v1;
```

Compare in constant time. `===` leaks how many characters matched.

```javascript theme={null}
if (expected.length !== received.length) return false;
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
```

## Complete examples

Each one prefers `v2` and falls back to `v1`, so the same code works before,
during and after a migration.

<CodeGroup>
  ```typescript TypeScript (Express) theme={null}
  import crypto from 'crypto';
  import express from 'express';

  const app = express();
  const SECRET = process.env.ZAVU_WEBHOOK_SECRET!;
  const MAX_AGE_SECONDS = 300;

  // Raw body. Not express.json().
  app.use('/webhooks/zavu', express.raw({ type: 'application/json' }));

  function verifyZavuSignature(rawBody: string, header: string, secret: string): boolean {
    if (!header) return false;

    const parts: Record<string, string> = {};
    for (const piece of header.split(',')) {
      const i = piece.indexOf('=');
      if (i > 0) parts[piece.slice(0, i)] = piece.slice(i + 1);
    }

    const timestamp = Number(parts.t);
    if (!Number.isFinite(timestamp)) return false;

    const age = Math.floor(Date.now() / 1000) - timestamp;
    if (age > MAX_AGE_SECONDS || age < -60) return false;

    // v2 covers the timestamp; v1 covers the body alone.
    const received = parts.v2 ?? parts.v1;
    if (!received) return false;
    const signedPayload = parts.v2 ? `${timestamp}.${rawBody}` : rawBody;

    const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

    // Length first: timingSafeEqual throws on a mismatch.
    if (expected.length !== received.length) return false;
    return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
  }

  app.post('/webhooks/zavu', (req, res) => {
    const rawBody = req.body.toString('utf8');
    const header = req.headers['x-zavu-signature'] as string;

    if (!verifyZavuSignature(rawBody, header, SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    // Answer fast, then work. A slow handler turns one event into five retries.
    res.status(200).send('OK');
    processEvent(JSON.parse(rawBody)).catch(console.error);
  });
  ```

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

  app = Flask(__name__)
  SECRET = os.environ["ZAVU_WEBHOOK_SECRET"]
  MAX_AGE_SECONDS = 300


  def verify_zavu_signature(raw_body: bytes, header: str, secret: str) -> bool:
      if not header:
          return False

      parts = {}
      for piece in header.split(","):
          key, sep, value = piece.partition("=")
          if sep:
              parts[key] = value

      try:
          timestamp = int(parts["t"])
      except (KeyError, ValueError):
          return False

      age = int(time.time()) - timestamp
      if age > MAX_AGE_SECONDS or age < -60:
          return False

      # v2 covers the timestamp; v1 covers the body alone.
      received = parts.get("v2") or parts.get("v1")
      if not received:
          return False

      signed = f"{timestamp}.".encode() + raw_body if "v2" in parts else raw_body
      expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

      return hmac.compare_digest(expected, received)


  @app.post("/webhooks/zavu")
  def zavu_webhook():
      raw_body = request.get_data()  # bytes, before any parsing
      header = request.headers.get("X-Zavu-Signature", "")

      if not verify_zavu_signature(raw_body, header, SECRET):
          return "Invalid signature", 401

      event = request.get_json()
      # Queue the work; answer now.
      enqueue(event)
      return "OK", 200
  ```

  ```ruby Ruby (Sinatra) theme={null}
  require 'openssl'
  require 'sinatra'

  SECRET = ENV['ZAVU_WEBHOOK_SECRET']
  MAX_AGE_SECONDS = 300

  def verify_zavu_signature(raw_body, header, secret)
    return false if header.nil? || header.empty?

    parts = {}
    header.split(',').each do |piece|
      key, _, value = piece.partition('=')
      parts[key] = value unless value.empty?
    end

    timestamp = Integer(parts['t'], exception: false)
    return false if timestamp.nil?

    age = Time.now.to_i - timestamp
    return false if age > MAX_AGE_SECONDS || age < -60

    # v2 covers the timestamp; v1 covers the body alone.
    received = parts['v2'] || parts['v1']
    return false if received.nil?

    signed = parts['v2'] ? "#{timestamp}.#{raw_body}" : raw_body
    expected = OpenSSL::HMAC.hexdigest('SHA256', secret, signed)

    OpenSSL.secure_compare(expected, received)
  end

  post '/webhooks/zavu' do
    raw_body = request.body.read
    header = request.env['HTTP_X_ZAVU_SIGNATURE']

    halt 401, 'Invalid signature' unless verify_zavu_signature(raw_body, header, SECRET)

    event = JSON.parse(raw_body)
    enqueue(event)
    status 200
    'OK'
  end
  ```

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

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

  const maxAgeSeconds = 300

  func verifyZavuSignature(rawBody []byte, header, secret string) bool {
  	if header == "" {
  		return false
  	}

  	parts := map[string]string{}
  	for _, piece := range strings.Split(header, ",") {
  		if k, v, ok := strings.Cut(piece, "="); ok {
  			parts[k] = v
  		}
  	}

  	timestamp, err := strconv.ParseInt(parts["t"], 10, 64)
  	if err != nil {
  		return false
  	}

  	age := time.Now().Unix() - timestamp
  	if age > maxAgeSeconds || age < -60 {
  		return false
  	}

  	// v2 covers the timestamp; v1 covers the body alone.
  	received, hasV2 := parts["v2"]
  	if !hasV2 {
  		received = parts["v1"]
  	}
  	if received == "" {
  		return false
  	}

  	mac := hmac.New(sha256.New, []byte(secret))
  	if hasV2 {
  		mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
  	}
  	mac.Write(rawBody)
  	expected := hex.EncodeToString(mac.Sum(nil))

  	return hmac.Equal([]byte(expected), []byte(received))
  }

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

  	if !verifyZavuSignature(rawBody, r.Header.Get("X-Zavu-Signature"), os.Getenv("ZAVU_WEBHOOK_SECRET")) {
  		http.Error(w, "Invalid signature", http.StatusUnauthorized)
  		return
  	}

  	w.WriteHeader(http.StatusOK)
  	w.Write([]byte("OK"))
  	go processEvent(rawBody)
  }
  ```

  ```php PHP theme={null}
  <?php

  const MAX_AGE_SECONDS = 300;

  function verifyZavuSignature(string $rawBody, ?string $header, string $secret): bool {
      if (!$header) return false;

      $parts = [];
      foreach (explode(',', $header) as $piece) {
          $i = strpos($piece, '=');
          if ($i !== false) {
              $parts[substr($piece, 0, $i)] = substr($piece, $i + 1);
          }
      }

      if (!isset($parts['t']) || !ctype_digit($parts['t'])) return false;
      $timestamp = (int) $parts['t'];

      $age = time() - $timestamp;
      if ($age > MAX_AGE_SECONDS || $age < -60) return false;

      // v2 covers the timestamp; v1 covers the body alone.
      $received = $parts['v2'] ?? $parts['v1'] ?? null;
      if ($received === null) return false;

      $signed = isset($parts['v2']) ? "{$timestamp}.{$rawBody}" : $rawBody;
      $expected = hash_hmac('sha256', $signed, $secret);

      return hash_equals($expected, $received);
  }

  $rawBody = file_get_contents('php://input');
  $header = $_SERVER['HTTP_X_ZAVU_SIGNATURE'] ?? null;

  if (!verifyZavuSignature($rawBody, $header, getenv('ZAVU_WEBHOOK_SECRET'))) {
      http_response_code(401);
      exit('Invalid signature');
  }

  http_response_code(200);
  echo 'OK';
  ```
</CodeGroup>

## Moving a webhook to v2

Three steps, and the middle one is what makes this safe.

**1. Turn on both signatures.**

```sh theme={null}
curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \
  -H "Authorization: Bearer $ZAVUDEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhookSignatureVersion": "v1+v2"}'
```

Both signatures arrive in the same header, sharing one `t`. Your current
receiver still verifies `v1` and notices nothing.

**2. Deploy a receiver that verifies `v2`, and watch real deliveries.**

The examples above already do this: they prefer `v2` when it is present. Confirm
in your own logs that deliveries are landing before moving on.

**3. Turn `v1` off.**

```sh theme={null}
curl -X PATCH https://api.zavu.dev/v1/senders/$SENDER_ID \
  -d '{"webhookSignatureVersion": "v2"}'
```

<Warning>
  Going from `v1` straight to `v2` is rejected with `400`. Step 2 is where you
  confirm your receiver works, and it is worth doing properly: a receiver that
  answers `200` before it verifies looks identical to a working one from our
  side, so a passing test request proves nothing. Watch your own logs.
</Warning>

## Check the timestamp, and be idempotent

Two separate things, and you want both.

Reject deliveries whose `t` is far from now, as the examples above do. And
handle repeats: **legitimate** retries are real deliveries with a fresh
timestamp and a valid signature. Zavu retries non-2xx responses with backoff,
and delivery is at-least-once, so you will see the same event twice.

Store `event.id` and ignore what you have already processed.

## Troubleshooting

### Every delivery returns 401

In order of likelihood:

1. **You are hashing the wrong payload.** Check `signatureVersion` on the
   sender. On `v1` hash the body alone; on `v2` hash `{t}.{body}`.
2. **A body parser ran first.** The signature covers the raw bytes.
   `JSON.stringify(JSON.parse(x))` is not always `x`.
3. **Wrong secret.** It is per sender. Regenerating it invalidates the old one
   immediately, with no overlap.
4. **Milliseconds.** `t` is in seconds.

### It worked, then stopped

Check whether the sender's `signatureVersion` changed, and whether the secret
was regenerated. Both take effect on the next delivery.

### Testing locally

Send yourself a signed request rather than disabling verification:

```sh theme={null}
BODY='{"id":"evt_test","type":"message.inbound","data":{}}'
SIG=$(node -e "
  const c=require('crypto');
  const t=Math.floor(Date.now()/1000);
  const b=process.argv[1];
  console.log('t='+t+',v2='+c.createHmac('sha256','$ZAVU_WEBHOOK_SECRET').update(t+'.'+b).digest('hex'));
" "$BODY")

curl -i -X POST localhost:3000/webhooks/zavu \
  -H "X-Zavu-Signature: $SIG" \
  -H "Content-Type: application/json" \
  -d "$BODY"
```

Expect `200`. Then change one character of the body and expect `401`. A
receiver that returns `200` to both is not verifying anything.

## Next Steps

* [Event Types](/guides/receiving-messages/events) - Understand webhook payloads
* [Webhooks](/guides/receiving-messages/webhooks) - Configure your endpoints
