# Verifying signatures

The signing scheme, code in three languages, and test vectors.

Anyone can `POST` JSON at your endpoint. The signature is what tells you a
delivery is ours — verify it before you read the body, not after.

## The scheme

HMAC-SHA256 over `"{timestamp}.{rawBody}"`, keyed with your endpoint's signing
secret, hex-encoded, and sent in one header:

```http
POST /webhooks/salaf HTTP/1.1
Content-Type: application/json
X-Salaf-Signature: t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8
X-Salaf-Event: order.created
X-Salaf-Event-Id: evt_01J4X8G9ABCDEFGHJKMNPQRSTV
```

| Part | Meaning                                                                                                                                                                                   |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `t`  | The Unix timestamp, in **seconds**, at which the request was signed.                                                                                                                      |
| `v1` | A hex HMAC-SHA256 digest. `v1` names the *scheme*, not the payload version — a future `v2=` would mean a different algorithm, and receivers that only understand `v1` would keep working. |

There may be **two `v1=` values**. That is a secret rotation in progress: the
old secret keeps co-signing for 24 hours so you can deploy the new one at your
own pace. Accept the delivery if *either* matches — see
[Rotating a secret](https://www.salafems.com/developers/webhook-rotation.md).

Three details carry the whole security argument, and skipping any one of them
produces code that looks like it verifies and does not:

- **The timestamp is inside the signed string.** Signing the body alone would
  let anyone who captured one delivery replay it forever with a fresh `t=`,
  because the header would still verify. Because `t` is signed, changing it
  invalidates the signature — which is what makes your freshness check
  meaningful rather than decorative.
- **The raw bytes are signed, not a parsed object.** See
  [the raw-body trap](#the-raw-body-trap).
- **The comparison must be constant-time.** `a === b` on a digest leaks,
  through timing, how many leading characters an attacker guessed right. That
  turns forging a 64-character hex string from impossible into a few thousand
  requests.

## Verifying

**Node.js**

```javascript
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300; // 5 minutes

/**
 * @param rawBody the EXACT bytes we sent, as a string or Buffer — never a
 *                re-serialised object.
 */
export function verifySalafSignature(rawBody, header, secret) {
  if (!header) return false;

  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=");
    if (key?.trim() === "t") timestamp = Number(value?.trim());
    if (key?.trim() === "v1") signatures.push(value?.trim());
  }
  if (!Number.isFinite(timestamp) || signatures.length === 0) return false;

  // Reject anything captured and replayed later. The timestamp is INSIDE the
  // signed string, so an attacker cannot simply put a fresh one in the header.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

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

  // During a secret rotation there are TWO v1= values; either may match.
  return signatures.some((candidate) => timingSafeEqual(expected, candidate));
}

// Never use === on a signature: the early exit leaks, through timing, how
// many leading characters an attacker guessed right.
function timingSafeEqual(a, b) {
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"));
}
```

**Python**

```python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300  # 5 minutes


def verify_salaf_signature(raw_body: bytes, header: str, secret: str) -> bool:
    """raw_body is the exact request body — bytes, not a parsed dict."""
    if not header:
        return False

    timestamp = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.partition("=")
        if key.strip() == "t":
            timestamp = value.strip()
        elif key.strip() == "v1":
            signatures.append(value.strip())

    if timestamp is None or not signatures:
        return False

    # Replay window. The timestamp is signed, so it cannot be swapped.
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    signed_payload = timestamp.encode() + b"." + raw_body
    expected = hmac.new(
        secret.encode(), signed_payload, hashlib.sha256
    ).hexdigest()

    # compare_digest, never ==: a plain comparison leaks the match length.
    # Two v1= values appear during a rotation; either may match.
    return any(hmac.compare_digest(expected, s) for s in signatures)
```

**PHP**

```php
<?php

const SALAF_TOLERANCE_SECONDS = 300; // 5 minutes

/** $rawBody must be the exact request body, not a re-encoded array. */
function verify_salaf_signature(string $rawBody, ?string $header, string $secret): bool
{
    if ($header === null || $header === '') {
        return false;
    }

    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, null);
        $key = trim((string) $key);
        if ($key === 't') {
            $timestamp = trim((string) $value);
        } elseif ($key === 'v1') {
            $signatures[] = trim((string) $value);
        }
    }

    if ($timestamp === null || $signatures === []) {
        return false;
    }

    // Replay window — the timestamp is part of what was signed.
    if (abs(time() - (int) $timestamp) > SALAF_TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

    // hash_equals, never ===. Two signatures ride along during a rotation.
    foreach ($signatures as $candidate) {
        if (hash_equals($expected, $candidate)) {
            return true;
        }
    }

    return false;
}
```

Then reject the delivery if verification fails. A `400` is the right answer:
we will retry it, but no number of retries will make an unsigned request valid,
and the failure is visible in the merchant's delivery log where they can act
on it.

## The five-minute window

Reject any delivery whose `t` is more than **300 seconds** from your own clock,
in either direction. We do not enforce this — you do, and the code above shows
where.

That window is a compromise between two real problems: a captured request
should not be replayable tomorrow, and two machines nobody synchronised will
disagree by a few seconds. Five minutes is comfortably more than clock skew and
comfortably less than useful to an attacker.

> **Note — A retried delivery still carries a fresh timestamp**
>
> Signing happens per attempt, not per event. A delivery retried 24 hours later
> is signed at the moment it is sent, so it passes your window check — while
> `created_at` in the body still tells you when the event actually happened.

## The raw-body trap

This is the single most common way a working integration breaks
intermittently, so it is worth stating bluntly: **verify against the exact
bytes you received.**

Re-serialising JSON produces different bytes on different platforms — key
order, unicode escaping, whitespace, number formatting. A receiver that
verifies against `JSON.stringify(req.body)` will match on most payloads and
fail on the ones containing a Bengali product name or a decimal that
round-trips differently. It looks like an intermittent network problem and it
is not.

Every popular framework parses the body for you by default, so getting the raw
bytes takes one deliberate line:

**Express**

```javascript
import express from "express";

const app = express();

// Option A — keep the parsed body AND capture the raw bytes beside it.
app.use(
  express.json({
    verify: (req, _res, buffer) => {
      req.rawBody = buffer;
    },
  }),
);

// Option B — for the webhook route only, do not parse at all.
app.post(
  "/webhooks/salaf",
  express.raw({ type: "application/json" }),
  (req, res) => {
    // req.body is a Buffer here — exactly the bytes we signed.
    const ok = verifySalafSignature(
      req.body,
      req.get("X-Salaf-Signature"),
      process.env.SALAF_WEBHOOK_SECRET,
    );
    if (!ok) return res.sendStatus(400);

    const event = JSON.parse(req.body.toString("utf8"));
    res.sendStatus(200);
    void handleLater(event);
  },
);

// What NOT to do: JSON.stringify(req.body). Re-serialising changes key
// order, unicode escaping and whitespace, so the digest will differ from
// ours on some payloads and match on others — the worst kind of bug.
```

**Django / Flask**

```python
# Django — request.body is the raw bytes, before any parsing.
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST


@csrf_exempt
@require_POST
def salaf_webhook(request):
    ok = verify_salaf_signature(
        request.body,  # bytes — do NOT use request.POST or json.dumps()
        request.headers.get("X-Salaf-Signature"),
        settings.SALAF_WEBHOOK_SECRET,
    )
    if not ok:
        return HttpResponse(status=400)

    event = json.loads(request.body)
    enqueue_event(event)  # queue it; answer immediately
    return HttpResponse(status=200)


# Flask — request.get_data() with cache=True, so calling .json later still
# works. request.get_json() alone would give you a dict you cannot verify.
@app.post("/webhooks/salaf")
def salaf_webhook_flask():
    raw = request.get_data(cache=True)
    if not verify_salaf_signature(
        raw, request.headers.get("X-Salaf-Signature"), SECRET
    ):
        return "", 400
    enqueue_event(json.loads(raw))
    return "", 200
```

**Laravel**

```php
<?php

// routes/web.php — exclude the webhook route from CSRF verification.
Route::post('/webhooks/salaf', [SalafWebhookController::class, 'handle'])
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

class SalafWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // getContent() is the raw body. $request->all() and $request->json()
        // are parsed structures — re-encoding them changes the bytes.
        $raw = $request->getContent();

        $ok = verify_salaf_signature(
            $raw,
            $request->header('X-Salaf-Signature'),
            config('services.salaf.webhook_secret'),
        );

        if (! $ok) {
            return response()->noContent(400);
        }

        $event = json_decode($raw, true);
        SalafEventJob::dispatch($event); // queue, then answer

        return response()->noContent(200);
    }
}
```

## Verify your implementation

These vectors are pinned by a test in our own codebase, so they cannot drift
away from what the server does. Run your verification code against them before
you point it at a live endpoint — if it disagrees here, it will disagree in
production, and you will be debugging it against real orders.

|                   |                                                                                    |
| ----------------- | ---------------------------------------------------------------------------------- |
| **Secret**        | `whsec_TestSecretForDocumentationVectorsOnly1`                                     |
| **Timestamp**     | `1754899200`                                                                       |
| **Raw body**      | `{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}`                      |
| **Signed string** | `1754899200.{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}`           |
| **Expected `v1`** | `dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8`                 |
| **Full header**   | `t=1754899200,v1=dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8` |

**Node.js**

```javascript
import crypto from "node:crypto";

const secret = "whsec_TestSecretForDocumentationVectorsOnly1";
const timestamp = 1754899200;
const body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}';

const signature = crypto
  .createHmac("sha256", secret)
  .update(`${timestamp}.${body}`, "utf8")
  .digest("hex");

console.log(signature);
// dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8

console.assert(
  signature === "dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8",
  "Your signing does not match Salaf's. Check that you sign " +
    "`timestamp + '.' + rawBody` and hex-encode the digest.",
);
```

**Python**

```python
import hashlib
import hmac

secret = "whsec_TestSecretForDocumentationVectorsOnly1"
timestamp = "1754899200"
body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}'

signature = hmac.new(
    secret.encode(),
    timestamp.encode() + b"." + body.encode(),
    hashlib.sha256,
).hexdigest()

print(signature)
# dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8

assert signature == (
    "dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8"
), "Your signing does not match Salaf's."
```

**PHP**

```php
<?php

$secret = 'whsec_TestSecretForDocumentationVectorsOnly1';
$timestamp = '1754899200';
$body = '{"id":"evt_01J4X8G9ABCDEFGHJKMNPQRSTV","type":"salaf.ping"}';

$signature = hash_hmac('sha256', $timestamp . '.' . $body, $secret);

echo $signature . PHP_EOL;
// dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8

assert($signature === 'dd3c17ef08611a833fd18718fb90d48d278db0f7f1ced1c1812aad359a718de8');
```

> **Warning — Note the body has no spaces**
>
> The raw body above is exactly 59 bytes with no whitespace between tokens —
> which is what we send, and what you must hash. If you retyped it from this
> page with pretty-printing, your digest will not match, and that mismatch is
> the raw-body trap in miniature.

If your digest matches, your signing is correct. The remaining things to check
in your own code are the ones the vector cannot exercise: that you compare in
constant time, that you enforce the timestamp window, and that you accept
either `v1` value when two are present.

## Where the secret lives

- **Shown once**, when the endpoint is created, and again only when you rotate
  it. It starts with `whsec_`.
- **Store it like a password** — an environment variable or a secret manager,
  never in source. The `whsec_` prefix exists so secret scanners can catch it
  if it does end up in a commit.
- **One secret per endpoint.** If you receive events at two URLs, they have two
  different secrets, and verifying with the wrong one fails exactly as if the
  request were forged.
- **We cannot show it to you again.** It is encrypted rather than hashed
  (signing needs the plaintext back), but there is no screen and no support
  process that prints it. If you lose it, rotate.
