# Verifying a webhook signature

> How to check that a delivery comes from SafeOnuba: HMAC-SHA256 over the raw body, constant-time comparison, a 5-minute window and secret rotation.

Page: https://safeonuba.com/en/developers/webhooks/verify-signature · SafeOnuba API v1 (`api_version: "2026-09-24"`), in preview: the contract may change.

Your receiver is at a public URL, so anyone can send it a `POST`. The signature is what proves a delivery comes from SafeOnuba and that nobody has tampered with it along the way. **Don’t process any event without verifying it.**

## How it’s signed

Each delivery carries two headers:

- `X-SafeOnuba-Timestamp`: the moment of signing, in Unix seconds.
- `X-SafeOnuba-Signature`: `v1=` followed by the signature in hexadecimal.

The signature is an HMAC-SHA256, with the destination’s secret (the `whsec_…` the API gave you when you created it) as the key, over the timestamp, a full stop and the body **exactly as it arrived**:

```text
v1=hex( HMAC_SHA256( secret, timestamp + "." + raw_body ) )
```

## What your receiver has to do

1. **Read the raw body**, as bytes, before any framework turns it into JSON. If you parse it and serialise it again, the whitespace or the order of the keys changes and the signature no longer matches.
2. **Reject it if the timestamp is more than 5 minutes off** your clock, in either direction. This stops someone replaying an old event they captured. Keep your server’s clock synchronised by NTP.
3. **Calculate the signature** with your secret over `timestamp + "." + body`.
4. **Compare it in constant time** against each signature in the header. An ordinary comparison (`==`) takes a different time depending on how many characters match, and that leaks information.
5. **Accept it if any of the signatures in the header matches.** During a secret rotation two arrive, separated by a comma.

If the signature isn’t valid, respond `401` and discard the body.

## A complete receiver

The three examples do the same thing: they verify, put the event on your queue (`enqueue`, which you provide) and respond `204` straight away.

```javascript
import { createHmac, timingSafeEqual } from 'node:crypto';
import { createServer } from 'node:http';

const SECRET = process.env.SAFEONUBA_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_S = 5 * 60;

export function isValidSignature(body, timestamp, header, secret = SECRET) {
  if (!body || !timestamp || !header) return false;

  // 1. The timestamp, within 5 minutes of our clock.
  const t = Number(timestamp);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false;

  // 2. The expected signature, over the raw body (a Buffer).
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(body).digest();

  // 3. Any of the signatures in the header will do, compared in constant time.
  return header.split(',').some((part) => {
    const [version, hex] = part.trim().split('=');
    if (version !== 'v1' || !/^[0-9a-f]{64}$/i.test(hex ?? '')) return false;
    return timingSafeEqual(Buffer.from(hex, 'hex'), expected);
  });
}

createServer((req, res) => {
  if (req.method !== 'POST' || req.url !== '/safeonuba') {
    res.writeHead(404).end();
    return;
  }
  const chunks = [];
  req.on('data', (chunk) => chunks.push(chunk));
  req.on('end', () => {
    const body = Buffer.concat(chunks); // raw: no JSON.parse before verifying
    const valid = isValidSignature(
      body,
      req.headers['x-safeonuba-timestamp'],
      req.headers['x-safeonuba-signature'],
    );
    if (!valid) {
      res.writeHead(401).end();
      return;
    }
    enqueue(JSON.parse(body.toString('utf8'))); // processed later, outside the request
    res.writeHead(204).end();
  });
}).listen(8080);
```

```python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

SECRET = os.environ["SAFEONUBA_WEBHOOK_SECRET"].encode()  # whsec_…
TOLERANCE_S = 5 * 60

app = Flask(__name__)

def is_valid_signature(body: bytes, timestamp: str | None, header: str | None) -> bool:
    if not timestamp or not header:
        return False

    # 1. The timestamp, within 5 minutes of our clock.
    try:
        t = int(timestamp)
    except ValueError:
        return False
    if abs(time.time() - t) > TOLERANCE_S:
        return False

    # 2. The expected signature, over the raw body.
    expected = hmac.new(SECRET, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()

    # 3. Any of the signatures in the header will do, compared in constant time.
    for part in header.split(","):
        version, _, signature = part.strip().partition("=")
        if version == "v1" and hmac.compare_digest(signature.lower(), expected):
            return True
    return False

@app.post("/safeonuba")
def receive():
    body = request.get_data()  # raw, before touching request.json
    if not is_valid_signature(
        body,
        request.headers.get("X-SafeOnuba-Timestamp"),
        request.headers.get("X-SafeOnuba-Signature"),
    ):
        abort(401)
    enqueue(request.get_json())  # processed later, outside the request
    return "", 204
```

```csharp
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var secret = Encoding.UTF8.GetBytes(builder.Configuration["SAFEONUBA_WEBHOOK_SECRET"]!); // whsec_…

app.MapPost("/safeonuba", async (HttpRequest req) =>
{
    // Raw: the whole body is read before deserialising anything.
    using var ms = new MemoryStream();
    await req.Body.CopyToAsync(ms);
    var body = ms.ToArray();

    if (!IsValidSignature(body, req.Headers["X-SafeOnuba-Timestamp"], req.Headers["X-SafeOnuba-Signature"], secret))
        return Results.Unauthorized();

    Enqueue(JsonDocument.Parse(body)); // processed later, outside the request
    return Results.NoContent();
});

app.Run();

static bool IsValidSignature(byte[] body, string? timestamp, string? header, byte[] secret)
{
    if (string.IsNullOrEmpty(timestamp) || string.IsNullOrEmpty(header)) return false;

    // 1. The timestamp, within 5 minutes of our clock.
    if (!long.TryParse(timestamp, out var t)) return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 5 * 60) return false;

    // 2. The expected signature, over the raw body.
    var signed = Encoding.UTF8.GetBytes(timestamp + ".").Concat(body).ToArray();
    var expected = HMACSHA256.HashData(secret, signed);

    // 3. Any of the signatures in the header will do, compared in constant time.
    foreach (var part in header.Split(','))
    {
        var pieces = part.Trim().Split('=', 2);
        if (pieces.Length != 2 || pieces[0] != "v1") continue;
        byte[] received;
        try { received = Convert.FromHexString(pieces[1]); }
        catch (FormatException) { continue; }
        if (CryptographicOperations.FixedTimeEquals(received, expected)) return true;
    }
    return false;
}
```

> **If you use a framework, switch off parsing on this route**
>
> Express, Fastify, Django REST or ASP.NET with `[FromBody]` turn the body into an object before it reaches your code, and from then on you can’t verify. In Express, for example, mount this route with `express.raw({ type: 'application/json' })` instead of `express.json()`.

## Rotating the secret

Rotate the secret if you think it has leaked, when someone who knew it leaves, or simply every so often. Nothing is interrupted:

1. Call `POST /v1/webhooks/{id}/rotate-secret`. The response carries the new secret (`secret`, this once only) and how long the previous one keeps signing (`previous_valid_until`).
2. For the next **24 hours**, each delivery carries **two signatures** in `X-SafeOnuba-Signature`, one with each secret, separated by a comma:

   ```text
   v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd,v1=6ffbb59b2300aae63f272406069a9788598b792a944a07aba816edb039989a39
   ```

3. Switch your receiver’s secret to the new one at any point in those 24 hours. Since the code above accepts the delivery if any one of the signatures matches, it works the same before and after the switch.
4. After the 24 hours, the previous secret stops signing.

A second rotation retires the first secret: there are never more than two at once.

## Common mistakes

- **Verifying over the re-serialised JSON.** The signature is calculated over the exact bytes that arrived.
- **Comparing with `==`.** Use `timingSafeEqual`, `hmac.compare_digest` or `CryptographicOperations.FixedTimeEquals`.
- **Ignoring the timestamp.** Without the 5-minute window, a captured event can be replayed whenever someone likes.
- **Keeping only the first signature.** During a rotation two arrive, and the one that matches your secret may be the second.
- **Responding after processing.** If your control room takes more than 5 seconds, the delivery counts as failed and is retried. Verify, enqueue and respond.
