# Verifying signatures

> Check the Nexafon-Signature header with the helpers in Node, PHP, Python and Go, or by hand, and rotate secrets without downtime.

Source: https://docs.nexafone.so/v1/webhooks/signatures/

Every delivery carries `Nexafon-Signature: t=<timestamp>,v1=<signature>`, where the signature is the hex
HMAC-SHA256 of `"<timestamp>.<raw body>"` keyed with the endpoint’s secret (`whsec_…`). Check it before trusting
anything in the body.

1. Read the **raw** body, exactly as received. Parsing and re-serialising JSON changes the bytes.
2. Split the header on `,`: take `t` and every `v1`.
3. Reject the delivery if `t` is more than **5 minutes** from your clock (it stops replays of captured deliveries).
4. Compute HMAC-SHA256 of `t + "." + body` with your secret; compare it with each `v1` in constant time.
5. Answer `400` if nothing matches. Then dedupe on the event `id`.

## With the helpers

The [server helpers](/v1/server/) do all of this in one call and return the parsed event.

```ts title="Node"
if (req.method === 'POST' && req.url === '/hooks/nexafon') {
  try {
    // The raw body, exactly as received: parsing it first changes the bytes the signature covers.
    const event = verifyWebhook(
      req.headers,
      await rawBody(req),
      process.env.NEXAFON_WEBHOOK_SECRET ?? '',
    )
    // Deliveries can repeat: skip an event.id you have already handled.
    console.log(event.type, event.id)
    res.writeHead(204).end()
  } catch (err) {
    if (!(err instanceof WebhookVerificationError)) throw err
    res.writeHead(400).end()
  }
  return
}
```

```php title="PHP"
// The raw body, exactly as received: parsing it first changes the bytes the signature covers.
$body = file_get_contents('php://input');
try {
    $event = Webhook::verify(getallheaders(), $body, getenv('NEXAFON_WEBHOOK_SECRET'));
} catch (WebhookVerificationException) {
    http_response_code(400);
    exit;
}
// Deliveries can repeat: skip an $event['id'] you have already handled.
error_log($event['type'] . ' ' . $event['id']);
http_response_code(204);
```

```python title="Python"
if self.path == "/hooks/nexafon":
    # The raw body, exactly as received: parsing it first changes the bytes the signature covers.
    raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
    try:
        event = nexafon.verify_webhook(dict(self.headers), raw, os.environ["NEXAFON_WEBHOOK_SECRET"])
    except nexafon.WebhookVerificationError:
        self.send_response(400)
        self.end_headers()
        return
    # Deliveries can repeat: skip an event["id"] you have already handled.
    print(event["type"], event["id"])
    self.send_response(204)
    self.end_headers()
    return
```

```go title="Go"
func nexafonWebhook(w http.ResponseWriter, r *http.Request) {
	// The raw body, exactly as received: parsing it first changes the bytes the signature covers.
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	ev, err := nexafon.VerifyWebhook(r.Header, body, os.Getenv("NEXAFON_WEBHOOK_SECRET"))
	if errors.Is(err, nexafon.ErrInvalidWebhook) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	// Deliveries can repeat: skip an ev.ID you have already handled.
	log.Println(ev.Type, ev.ID)
	w.WriteHeader(http.StatusNoContent)
}
```

## Without a helper

Self-contained functions you can copy; Nexafon’s backend tests run each of them against real signed deliveries.

```js title="verify.mjs"
import { createHmac, timingSafeEqual } from 'node:crypto'

// Pass the raw request body exactly as received, before any JSON parsing.
export function verifyNexafonWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
  let timestamp = ''
  const signatures = []
  for (const part of signatureHeader.split(',')) {
    const [key, value] = part.trim().split('=')
    if (key === 't') timestamp = value
    if (key === 'v1') signatures.push(value)
  }
  if (!/^\d+$/.test(timestamp)) return false
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
  return signatures.some((signature) => {
    const given = Buffer.from(signature, 'hex')
    return given.length === expected.length && timingSafeEqual(given, expected)
  })
}
```

```php title="verify.php"
<?php

// Pass the raw request body exactly as received: file_get_contents('php://input').
function verify_nexafon_webhook(string $rawBody, string $signatureHeader, string $secret, int $toleranceSeconds = 300): bool
{
    $timestamp = '';
    $signatures = [];
    foreach (explode(',', $signatureHeader) as $part) {
        $pair = explode('=', trim($part), 2);
        if (count($pair) !== 2) {
            continue;
        }
        if ($pair[0] === 't') {
            $timestamp = $pair[1];
        } elseif ($pair[0] === 'v1') {
            $signatures[] = $pair[1];
        }
    }
    if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}
```

```python title="verify.py"
import hashlib
import hmac
import time


def verify_nexafon_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    """Pass the raw request body exactly as received, before any JSON parsing."""
    timestamp = ""
    signatures = []
    for part in signature_header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)
```

```go title="verify.go"
package nexafon

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strconv"
	"strings"
	"time"
)

// VerifyWebhook checks the Nexafon-Signature header against the raw request body, exactly as received.
func VerifyWebhook(rawBody []byte, signatureHeader, secret string, tolerance time.Duration) bool {
	var timestamp string
	var signatures []string
	for _, part := range strings.Split(signatureHeader, ",") {
		key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch key {
		case "t":
			timestamp = value
		case "v1":
			signatures = append(signatures, value)
		}
	}
	seconds, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	if age := time.Since(time.Unix(seconds, 0)); age > tolerance || age < -tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(timestamp + "."))
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, signature := range signatures {
		given, err := hex.DecodeString(signature)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```

## Rotating the secret

`POST …/webhook-endpoints/{endpointId}/rotate-secret` returns a new secret. For `keepPreviousHours` (default 24, at
most 168) every delivery is signed with **both** secrets: the header then carries two `v1` values, and a receiver
holding either one verifies it. Deploy the new secret within that window; the helpers accept a list of secrets
meanwhile.
