# 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](https://www.zavu.dev/docs/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.

## Which scheme is my webhook on?

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

```json
{
  "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
// 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
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
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
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
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.

```typescript
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
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
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
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

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';
```

## Moving a webhook to v2

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

**1. Turn on both signatures.**

```sh
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
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.

## 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
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](https://www.zavu.dev/docs/guides/receiving-messages/events) - Understand webhook payloads
- [Webhooks](https://www.zavu.dev/docs/guides/receiving-messages/webhooks) - Configure your endpoints
