# Verify the webhook signature

> Code in Node, PHP, Python and .NET, pseudocode for Delphi and 4D, and test vectors to prove your own code against.

Your webhook receiver's address is no secret. The signature is the only thing that shows a delivery comes from Rivr and was not changed on the way. The Node, PHP, Python and .NET examples are files that Rivr's CI runs against the test vectors in this guide, so the code you copy has been tested.

- Market: Sweden (`se`). Norway differs in currency, payment methods and what happens when nobody pays: https://developers.rivr.io/guides/verify-signatures.md?market=no
- Language: English (`en`). Svenska: https://developers.rivr.io/guides/verify-signatures.md?lang=sv
- 7 min read · 6 steps
- Page in the portal: https://developers.rivr.io/guides/verify-signatures

Every example targets the sandbox (`https://api.test.rivr.io`) and needs test credentials. A platform client's tokens come from `https://auth.test.rivr.io`, an organisation key's from `https://auth.test.rivr.tech`.

## 1. The clinic's path: callbackUrl

*Rivr → Journal system*

The order webhook goes to `callbackUrl` on the order, or to the address configured for the clinic or the platform. Signing is optional here. When at least one signing key is active the delivery carries `Rivr-Signature`, with one `v1` per active key on the clinic **and** on its platform. Without a key the delivery arrives unsigned.

The example is the test vector `clinic-and-platform`: two `v1`, one for the clinic's key and one for the platform's.

> **Warning.** A delivery without `Rivr-Signature` is not an error on this path, but it is no proof either. Decide beforehand what you do with it: log it, and reject it if you know a key exists.

**Webhook to you** (`Completed`)

```http
POST /webhooks/rivr
Content-Type: application/json; charset=utf-8
Rivr-Signature: t=1789640080,v1=9da00f8f153e4bb177e9b77812b56ba87105b31ae0583eb509bb1c6a5cafbe14,v1=d92f0c9586e8f4a0aa5cd4e7a40d54f3855ccc122501e01ab9070db3980d2feb

{"id":"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14","type":"Order","merchantId":"e26f9eeb-dd71-4f40-a508-d7344409430d","status":"Completed","data":{"CreatedDate":"2026-09-17T10:12:03Z","Reference":"visit-48213","CompletedDate":"2026-09-17T10:14:38Z","PaymentMethod":"Swish","Metadata":{"bookingId":"48213"}}}
```

## 2. Your organisation's endpoints

*Rivr → Journal system*

Endpoints you add under Webhooks in the portal receive the events of the orders your organisation's own API keys create. Every delivery is signed, always, with one `v1` per active key on the endpoint, and the `Rivr-Webhook-Id` header comes along.

The body has the same shape on both paths, and the signature is computed the same way. The example is the test vector `organization-endpoint`.

> **Rule.** There is no unsigned delivery here. If the signature is missing or does not match: answer `401` and do nothing.

**Webhook to you** (`Pending`)

```http
POST /webhooks/rivr
Content-Type: application/json; charset=utf-8
Rivr-Signature: t=1789640140,v1=dee71e2d4501c5daa145610df2ac01e11cf8cd0fbf935d96f860ca17a9f68fdf
Rivr-Webhook-Id: d7c83c3b-2694-49df-b703-022a5440bcd8

{"id":"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14","type":"Order","merchantId":"e26f9eeb-dd71-4f40-a508-d7344409430d","status":"Pending","data":{"CreatedDate":"2026-09-17T10:12:03Z","Reference":"visit-48213","Metadata":{"bookingId":"48213"}}}
```

## 3. Compute the signature

*Journal system*

The header looks like `t=<unix seconds>,v1=<hex>[,v1=<hex>…]`.

1. Split the header at commas and each part at its first `=`. Keep `t` and every `v1`, and skip names you don't recognise.

2. Reject the delivery if `t` is more than 300 seconds from your clock, in either direction.

3. Compute HMAC-SHA256 with the whole secret as the key, `whsec_` prefix included, over `t`, a dot and the raw body exactly as it arrived.

4. Write the result as lowercase hex and compare it in constant time with every `v1`. One match is enough.

**Why:** `t` is part of what is signed. A recorded delivery therefore can't be sent again with a new timestamp, and the five-minute window shuts out the old one.

> **Warning.** Sign the **raw** body. Rivr writes characters outside ASCII as JSON escapes, so JSON that has been parsed and serialised again has other bytes and will not match. The key is the **whole** secret, `whsec_` included.

## 4. Verify in your code

*Journal system*

The function takes the header, the raw body, your secrets and, in tests, the clock. It answers yes if any `v1` matches any of the secrets and `t` is fresh. At the bottom of each file is how to wire it into a common framework.

Node, PHP, Python and .NET are exactly the files Rivr's CI runs against the test vectors. Delphi and 4D are pseudocode: translate it and prove the result against the vectors in the next step.

**What your system does:** Answer `401` when verification fails, not `400`. Rivr never retries a delivery that got `400`, but a `401` is retried on schedule and gets through once you have fixed the key. Take the secrets as a list: while the key rotates you hold two, and remove the old one once Rivr has stopped signing with it.

**Node**

```js
// Verify the Rivr-Signature header before you trust a webhook from Rivr.
// Node 18 or later, no dependencies. Rivr runs this exact file against its test vectors.
//
//   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
//   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))
import { createHmac, timingSafeEqual } from "node:crypto";

/** How far t may be from your clock, in seconds, either way. */
export const TOLERANCE_SECONDS = 300;

/**
 * @param {?string} header                   the Rivr-Signature header, or nothing when it is missing
 * @param {Buffer | string} rawBody           the body exactly as it arrived, never re-serialised JSON
 * @param {string[] | string} secrets         your signing secret, or two of them while you rotate
 * @param {number} [now]                      unix seconds; the clock unless you are testing
 * @returns {boolean} true if any v1 matches any of your secrets and t is fresh
 */
export function verifyRivrSignature(header, rawBody, secrets, now = Math.floor(Date.now() / 1000)) {
  if (!header) return false;

  let timestamp;
  const signatures = [];
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const name = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (name === "t") timestamp = value;
    else if (name === "v1" && value) signatures.push(value);
  }
  if (!timestamp || !/^[0-9]+$/.test(timestamp) || signatures.length === 0) return false;

  // t is part of what is signed, so an old delivery can't be replayed with a new t.
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const body = typeof rawBody === "string" ? Buffer.from(rawBody, "utf8") : rawBody;
  // One secret as a plain string works too. Looping over the string itself would try every character as a key.
  const keys = typeof secrets === "string" ? [secrets] : (secrets ?? []);
  for (const secret of keys) {
    // An empty secret, such as a variable that is set but not filled in, is a key anyone can sign with.
    if (typeof secret !== "string" || secret === "") continue;
    const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.`).update(body).digest("hex"));
    for (const signature of signatures) {
      const presented = Buffer.from(signature);
      if (presented.length === expected.length && timingSafeEqual(presented, expected)) return true;
    }
  }
  return false;
}

// In your app (Express). Keep the body raw: express.json() would hand you re-serialised JSON.
//
// app.post("/webhooks/rivr", express.raw({ type: "application/json" }), async (req, res) => {
//   if (!verifyRivrSignature(req.get("Rivr-Signature"), req.body, [process.env.RIVR_WEBHOOK_SECRET])) {
//     return res.sendStatus(401); // not 400: Rivr never retries a 400, and the retry is what saves the delivery once your key is right
//   }
//   // Rivr-Webhook-Id is the same on every retry of a delivery, and every delivery carries it: on the callbackUrl
//   // path and to your organisation's endpoints alike. The worker records it in the same transaction as the work,
//   // under a unique key, and skips an id it has already seen.
//   await queue.add("rivr-webhook", { id: req.get("Rivr-Webhook-Id") ?? null, body: req.body.toString("utf8") });
//   res.sendStatus(200); // within 5 seconds
// });
```

**PHP**

```php
<?php

declare(strict_types=1);

// Verify the Rivr-Signature header before you trust a webhook from Rivr.
// PHP 7.4 or later, no extensions beyond the standard ones. Rivr runs this exact file against its test vectors.
//
//   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
//   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))

/**
 * @param string|null $header  the Rivr-Signature header
 * @param string      $rawBody the body exactly as it arrived: file_get_contents('php://input'), never re-encoded JSON
 * @param string[]    $secrets your signing secret, or two of them while you rotate
 * @param int|null    $now     unix seconds; the clock unless you are testing
 * @return bool true if any v1 matches any of your secrets and t is fresh
 */
function rivr_verify_signature(?string $header, string $rawBody, array $secrets, ?int $now = null): bool
{
    $toleranceSeconds = 300; // how far t may be from your clock, either way

    if ($header === null || $header === '') {
        return false;
    }

    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $i = strpos($part, '=');
        if ($i === false || $i === 0) {
            continue;
        }
        $name = trim(substr($part, 0, $i));
        $value = trim(substr($part, $i + 1));
        if ($name === 't') {
            $timestamp = $value;
        } elseif ($name === 'v1' && $value !== '') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || preg_match('/\A[0-9]+\z/', $timestamp) !== 1 || count($signatures) === 0) {
        return false;
    }

    // t is part of what is signed, so an old delivery can't be replayed with a new t.
    $now = $now ?? time();
    if (abs($now - (int) $timestamp) > $toleranceSeconds) {
        return false;
    }

    foreach ($secrets as $secret) {
        // An empty secret, such as a variable that is set but not filled in, is a key anyone can sign with.
        // getenv() answers false for a variable that is not set at all.
        if (!is_string($secret) || $secret === '') {
            continue;
        }
        $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
        foreach ($signatures as $signature) {
            if (hash_equals($expected, $signature)) {
                return true;
            }
        }
    }
    return false;
}

// In your app (plain PHP; in Laravel use $request->getContent() and $request->header(...)).
//
// $raw = file_get_contents('php://input');
// if (!rivr_verify_signature($_SERVER['HTTP_RIVR_SIGNATURE'] ?? null, $raw, [getenv('RIVR_WEBHOOK_SECRET')])) {
//     http_response_code(401); // not 400: Rivr never retries a 400, and the retry is what saves the delivery once your key is right
//     exit;
// }
// // Rivr-Webhook-Id is the same on every retry of a delivery, and every delivery carries it: on the callbackUrl
// // path and to your organisation's endpoints alike. The worker records it in the same transaction as the work,
// // under a unique key, and skips an id it has already seen.
// $queue->push('rivr-webhook', ['id' => $_SERVER['HTTP_RIVR_WEBHOOK_ID'] ?? null, 'body' => $raw]);
// http_response_code(200); // within 5 seconds
```

**Python**

```python
# Verify the Rivr-Signature header before you trust a webhook from Rivr.
# Python 3.8 or later, standard library only. Rivr runs this exact file against its test vectors.
#
#   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
#   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))
import hashlib
import hmac
import re
import time
from typing import Iterable, Optional, Union

# How far t may be from your clock, in seconds, either way.
TOLERANCE_SECONDS = 300

_DIGITS = re.compile(r"[0-9]+")


def verify_rivr_signature(
    header: Optional[str],
    raw_body: bytes,
    secrets: Union[str, Iterable[str]],
    now: Optional[float] = None,
) -> bool:
    """True if any v1 matches any of your secrets and t is fresh.

    header:   the Rivr-Signature header
    raw_body: the body exactly as it arrived, never re-serialised JSON
    secrets:  your signing secret, or two of them while you rotate
    now:      unix seconds; the clock unless you are testing
    """
    if not header:
        return False

    timestamp = None
    signatures = []
    for part in header.split(","):
        name, separator, value = part.partition("=")
        name, value = name.strip(), value.strip()
        if not separator or not name:
            continue
        if name == "t":
            timestamp = value
        elif name == "v1" and value:
            signatures.append(value)
    if timestamp is None or not _DIGITS.fullmatch(timestamp) or not signatures:
        return False

    # t is part of what is signed, so an old delivery can't be replayed with a new t.
    if now is None:
        now = time.time()
    if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    message = timestamp.encode("ascii") + b"." + raw_body
    # One secret as a plain string works too. Looping over the string itself would try every character as a key.
    if isinstance(secrets, str):
        secrets = [secrets]
    for secret in secrets or ():
        # An empty secret, such as a variable that is set but not filled in, is a key anyone can sign with.
        if not isinstance(secret, str) or not secret:
            continue
        expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest().encode("ascii")
        for signature in signatures:
            if hmac.compare_digest(expected, signature.encode("utf-8")):
                return True
    return False


# In your app (Flask; in Django use request.body, in FastAPI await request.body()).
#
# @app.post("/webhooks/rivr")
# def rivr_webhook():
#     raw = request.get_data()
#     if not verify_rivr_signature(request.headers.get("Rivr-Signature"), raw, [os.environ["RIVR_WEBHOOK_SECRET"]]):
#         return "", 401  # not 400: Rivr never retries a 400, and the retry is what saves the delivery once your key is right
#     # Rivr-Webhook-Id is the same on every retry of a delivery, and every delivery carries it: on the callbackUrl
#     # path and to your organisation's endpoints alike. The worker records it in the same transaction as the work,
#     # under a unique key, and skips an id it has already seen.
#     queue.enqueue("rivr-webhook", {"id": request.headers.get("Rivr-Webhook-Id"), "body": raw.decode("utf-8")})
#     return "", 200  # within 5 seconds
```

**C#**

```csharp
// Verify the Rivr-Signature header before you trust a webhook from Rivr.
// No packages. Builds for .NET 6 and later and, through netstandard2.0, for .NET Framework 4.6.2 and later.
// Rivr runs this exact file against its test vectors.
//
//   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
//   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;

public static class RivrWebhookSignature
{
    /// <summary>How far t may be from your clock, in seconds, either way.</summary>
    public const int ToleranceSeconds = 300;

    /// <summary>True if any v1 matches any of your secrets and t is fresh.</summary>
    /// <param name="header">The Rivr-Signature header, or null when it is missing.</param>
    /// <param name="rawBody">The body exactly as it arrived, never re-serialised JSON.</param>
    /// <param name="secrets">Your signing secret, or two of them while you rotate.</param>
    /// <param name="nowUnixSeconds">Unix seconds; null means the clock.</param>
    public static bool Verify(string header, byte[] rawBody, IEnumerable<string> secrets, long? nowUnixSeconds = null)
    {
        if (string.IsNullOrEmpty(header) || rawBody == null || secrets == null)
        {
            return false;
        }

        string timestamp = null;
        var signatures = new List<string>();
        foreach (var part in header.Split(','))
        {
            var i = part.IndexOf('=');
            if (i <= 0)
            {
                continue;
            }
            var name = part.Substring(0, i).Trim();
            var value = part.Substring(i + 1).Trim();
            if (name == "t")
            {
                timestamp = value;
            }
            else if (name == "v1" && value.Length > 0)
            {
                signatures.Add(value);
            }
        }

        long t;
        if (timestamp == null || !IsDigits(timestamp) || signatures.Count == 0
            || !long.TryParse(timestamp, NumberStyles.None, CultureInfo.InvariantCulture, out t))
        {
            return false;
        }

        // t is part of what is signed, so an old delivery can't be replayed with a new t.
        var now = nowUnixSeconds ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        if (Math.Abs(now - t) > ToleranceSeconds)
        {
            return false;
        }

        var prefix = Encoding.UTF8.GetBytes(timestamp + ".");
        var message = new byte[prefix.Length + rawBody.Length];
        Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length);
        Buffer.BlockCopy(rawBody, 0, message, prefix.Length, rawBody.Length);

        foreach (var secret in secrets)
        {
            // An empty secret, such as a setting that exists but is not filled in, is a key anyone can sign with.
            if (string.IsNullOrEmpty(secret))
            {
                continue;
            }

            string expected;
            using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
            {
                expected = ToLowerHex(hmac.ComputeHash(message));
            }

            foreach (var signature in signatures)
            {
                if (FixedTimeEquals(expected, signature))
                {
                    return true;
                }
            }
        }
        return false;
    }

    private static bool IsDigits(string value)
    {
        if (value.Length == 0)
        {
            return false;
        }
        foreach (var c in value)
        {
            if (c < '0' || c > '9')
            {
                return false;
            }
        }
        return true;
    }

    private static string ToLowerHex(byte[] bytes)
    {
        var sb = new StringBuilder(bytes.Length * 2);
        foreach (var b in bytes)
        {
            sb.Append(b.ToString("x2", CultureInfo.InvariantCulture));
        }
        return sb.ToString();
    }

    // Compares every character, so the time taken does not reveal how much of a guess was right.
    private static bool FixedTimeEquals(string expected, string presented)
    {
        if (expected.Length != presented.Length)
        {
            return false;
        }
        var difference = 0;
        for (var i = 0; i < expected.Length; i++)
        {
            difference |= expected[i] ^ presented[i];
        }
        return difference == 0;
    }
}

// In your app (ASP.NET Core minimal API). Read the body as bytes, before any model binding touches it.
//
// app.MapPost("/webhooks/rivr", async (HttpRequest request, IConfiguration config, IWebhookQueue queue) =>
// {
//     using var buffer = new MemoryStream();
//     await request.Body.CopyToAsync(buffer);
//     var raw = buffer.ToArray();
//
//     if (!RivrWebhookSignature.Verify(request.Headers["Rivr-Signature"], raw, new[] { config["Rivr:WebhookSecret"] }))
//         return Results.Unauthorized(); // not 400: Rivr never retries a 400, and the retry is what saves the delivery once your key is right
//
//     // Rivr-Webhook-Id is the same on every retry of a delivery, and every delivery carries it: on the callbackUrl
//     // path and to your organisation's endpoints alike. The worker records it in the same transaction as the work,
//     // under a unique key, and skips an id it has already seen.
//     string? webhookId = request.Headers["Rivr-Webhook-Id"];
//     await queue.EnqueueAsync(webhookId, Encoding.UTF8.GetString(raw));
//     return Results.Ok(); // within 5 seconds
// });
```

**Delphi (pseudocode)**

```pascal
// Pseudocode in Delphi style. Rivr's CI does not compile it: check your own version against the test vectors.
//
//   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
//   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))
//
// HMAC-SHA256: THashSHA2.GetHMACAsBytes in System.Hash (Delphi XE8 and later), or Indy's TIdHMACSHA256.
// Whichever you use: the key and the message as UTF-8 bytes, the result as lowercase hex.

function VerifyRivrSignature(const Header: string; const RawBody: TBytes;
  const Secrets: array of string; const NowUnix: Int64): Boolean;
// Header:  the Rivr-Signature header ('' when it is missing)
// RawBody: the body bytes exactly as they arrived, never re-serialised JSON
// Secrets: your signing secret, or two of them while you rotate
// NowUnix: the current time in unix seconds (UTC)
const
  ToleranceSeconds = 300;   // how far t may be from your clock, either way
var
  Part, Name, Value, Timestamp, Secret, Expected, Signature: string;
  Signatures: TList<string>;
  Message: TBytes;
  T: Int64;
  I: Integer;
begin
  Result := False;
  if Header = '' then Exit;

  Timestamp := '';
  Signatures := TList<string>.Create;
  try
    for Part in Header.Split([',']) do
    begin
      I := Part.IndexOf('=');                      // zero-based, -1 when there is none
      if I <= 0 then Continue;
      Name := Part.Substring(0, I).Trim;
      Value := Part.Substring(I + 1).Trim;
      if Name = 't' then
        Timestamp := Value
      else if (Name = 'v1') and (Value <> '') then
        Signatures.Add(Value);
    end;

    if (Timestamp = '') or not OnlyDigits(Timestamp) or (Signatures.Count = 0) then Exit;
    if not TryStrToInt64(Timestamp, T) then Exit;

    // t is part of what is signed, so an old delivery can't be replayed with a new t.
    if Abs(NowUnix - T) > ToleranceSeconds then Exit;

    // "<t>." followed by the body exactly as it arrived.
    Message := Concat(TEncoding.UTF8.GetBytes(Timestamp + '.'), RawBody);

    for Secret in Secrets do
    begin
      // An empty secret, such as a setting that exists but is not filled in, is a key anyone can sign with.
      if Secret = '' then Continue;
      Expected := LowerHex(THashSHA2.GetHMACAsBytes(Message, TEncoding.UTF8.GetBytes(Secret)));
      for Signature in Signatures do
        if ConstantTimeEquals(Expected, Signature) then
          Exit(True);
    end;
  finally
    Signatures.Free;
  end;
end;

// OnlyDigits(S):            True when S is not empty and every character is '0'..'9'.
// LowerHex(Bytes):          two lowercase hex digits per byte.
// ConstantTimeEquals(A, B): False when the lengths differ; otherwise OR together (Ord(A[i]) xor Ord(B[i]))
//                           over every character and compare the result with 0, so the time taken does not
//                           reveal how much of a guess was right.
//
// In a WebBroker or Horse handler: verify first and answer 401 if it fails (not 400, which Rivr never retries).
// Otherwise put the delivery on a queue with its Rivr-Webhook-Id and answer 200 within 5 seconds. Every delivery
// carries the id, on the callbackUrl path and to your organisation's endpoints alike. The worker records it in the
// same transaction as the work, under a unique key, and skips an id it has already seen.
```

**4D (pseudocode)**

```
// Pseudocode in 4D style. Rivr's CI does not run it: check your own version against the test vectors.
//
//   Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]   one v1 per active signing key
//   v1 = hex(HMAC-SHA256(key: the whole secret, whsec_ included; message: "<t>.<raw body>"))
//
// Method rivrVerifySignature
//   $1 Text        the Rivr-Signature header ("" when it is missing)
//   $2 Blob        the body exactly as it arrived (WEB GET HTTP BODY), never re-serialised JSON
//   $3 Collection  your signing secret, or two of them while you rotate
//   $4 Real        the current time in unix seconds (UTC). A Real, because unix time outgrows a Longint in 2038.
//   $0 Boolean     True if any v1 matches any of your secrets and t is fresh

$header:=$1
$rawBody:=$2
$secrets:=$3
$now:=$4

$valid:=False
$timestamp:=""
$signatures:=New collection

For each ($part; Split string($header; ","))
  $i:=Position("="; $part)
  If ($i>1)
    $name:=Trim(Substring($part; 1; $i-1))
    $value:=Trim(Substring($part; $i+1))
    Case of
      : ($name="t")
        $timestamp:=$value
      : (($name="v1") & ($value#""))
        $signatures.push($value)
    End case
  End if
End for each

If (($timestamp#"") & OnlyDigits($timestamp) & ($signatures.length>0))
  // t is part of what is signed, so an old delivery can't be replayed with a new t.
  If (Abs($now-Num($timestamp))<=300)
    // "<t>." followed by the body exactly as it arrived.
    TEXT TO BLOB($timestamp+"."; $message; UTF8 text without length)
    COPY BLOB($rawBody; $message; 0; BLOB size($message); BLOB size($rawBody))

    For each ($secret; $secrets) Until ($valid)
      // An empty secret, such as a setting that exists but is not filled in, is a key anyone can sign with.
      If ($secret#"")
        $expected:=HmacSha256Hex($secret; $message)
        For each ($signature; $signatures) Until ($valid)
          $valid:=ConstantTimeEquals($expected; $signature)
        End for each
      End if
    End for each
  End if
End if

$0:=$valid

// OnlyDigits($text):            True when $text is not empty and every character is "0".."9".
// Trim($text):                  $text without spaces at either end.
// ConstantTimeEquals($a; $b):   False when the lengths differ; otherwise OR together the XOR of the character codes
//                               of every pair and compare the result with 0, so the time taken does not reveal how
//                               much of a guess was right.
// HmacSha256Hex($secret; $message), as RFC 2104 describes, if your 4D has no HMAC command of its own:
//   K     := the UTF-8 bytes of $secret, whsec_ included. If K is longer than 64 bytes, K := SHA-256(K).
//            Pad K with zero bytes to 64 bytes.
//   inner := SHA-256((every byte of K XOR 0x36) followed by $message)   -- the 32 raw bytes, not hex
//   mac   := SHA-256((every byte of K XOR 0x5C) followed by inner)
//   Return mac as lowercase hex.
//   Generate digest(...; SHA256 digest) answers in hex: turn the inner hash back into 32 bytes before the outer round.
//
// In the web method: verify first and answer 401 if it fails (not 400, which Rivr never retries). Otherwise store
// the delivery with its Rivr-Webhook-Id and answer 200 within 5 seconds. Every delivery carries the id, on the
// callbackUrl path and to your organisation's endpoints alike. The worker records it in the same transaction as the
// work, under a unique key, and skips an id it has already seen.
```

## 5. Test against the vectors

*Journal system*

The vectors are made up: the secrets, ids and bodies are used nowhere else. For every vector, `verify(headers["Rivr-Signature"], body, secrets, now)` must return `valid`. Put the file in your own test suite, and you notice at once if a new framework starts handing you re-serialised JSON.

`signing` shows how Rivr made the header: the keys, the timestamp and, when it differs, the body that was signed. You don't need it to verify. `path` says which path the vector belongs to.

**Why:** The vectors catch the mistakes that otherwise show up in production: re-serialised JSON, the secret without `whsec_`, only the first `v1` compared, or a time window that only counts in one direction.

**Test vectors**

```json
{
  "about": "Made-up test vectors for the Rivr-Signature header. Every secret, id and body in this file is invented and used nowhere else.",
  "header": "Rivr-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]  (one v1 per active signing key)",
  "algorithm": "v1 = lowercase hex of HMAC-SHA256(key: the UTF-8 bytes of the whole secret, whsec_ included; message: the UTF-8 bytes of \"<t>.<raw body>\")",
  "toleranceSeconds": 300,
  "howToUse": "For every vector, verify(headers[\"Rivr-Signature\"] (absent means missing), body, secrets, now) must return valid. \"signing\" says how Rivr made the header; you do not need it to verify.",
  "vectors": [
    {
      "name": "one-key",
      "path": "both",
      "about": "One active key. The common case on both delivery paths.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": true
    },
    {
      "name": "organization-endpoint",
      "path": "organization",
      "about": "An organization's endpoint: always signed, and Rivr-Webhook-Id comes along. The id is not part of what is signed.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640140
      },
      "headers": {
        "Rivr-Signature": "t=1789640140,v1=dee71e2d4501c5daa145610df2ac01e11cf8cd0fbf935d96f860ca17a9f68fdf",
        "Rivr-Webhook-Id": "d7c83c3b-2694-49df-b703-022a5440bcd8"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Pending\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640152,
      "valid": true
    },
    {
      "name": "clinic-and-platform",
      "path": "callbackUrl",
      "about": "The callbackUrl path signs once per active key on the clinic and on its platform. You hold only the platform key: one matching v1 is enough.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-K-pahittad-nyckel-inte-riktig-00",
          "whsec_TESTVEKTOR-P-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=9da00f8f153e4bb177e9b77812b56ba87105b31ae0583eb509bb1c6a5cafbe14,v1=d92f0c9586e8f4a0aa5cd4e7a40d54f3855ccc122501e01ab9070db3980d2feb"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-P-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": true
    },
    {
      "name": "rotation-by-rivr",
      "path": "organization",
      "about": "The key is being rotated: Rivr signs with the old and the new key. You have already switched to the new one.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-G-pahittad-nyckel-inte-riktig-00",
          "whsec_TESTVEKTOR-N-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=a8ae751e27ee1357098490b290177b10f41acef995d123792ad4e7fcd8e1aa89,v1=251602a0e69adf5731ee1bdbd57a9becafe2dc1b5b034557af60b9ee93a242ad",
        "Rivr-Webhook-Id": "885b416d-ffe0-47a4-91a7-e42d9f1a424b"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-N-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": true
    },
    {
      "name": "rotation-by-you",
      "path": "both",
      "about": "Rivr signs with the new key only; you still hold both while you roll out the new one.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-N-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=251602a0e69adf5731ee1bdbd57a9becafe2dc1b5b034557af60b9ee93a242ad"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-G-pahittad-nyckel-inte-riktig-00",
        "whsec_TESTVEKTOR-N-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": true
    },
    {
      "name": "escaped-unicode",
      "path": "both",
      "about": "Rivr writes every character outside ASCII as a JSON escape (Å is six characters: backslash, u, 00C5). Verify the bytes you received, escapes and all.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=031fee715cf709d70f1c15e048d677fcc317bb52927f1127945e74a092f88399"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Created\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"Metadata\":{\"bookingId\":\"48213\",\"visitType\":\"\\u00C5terbes\\u00F6k\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": true
    },
    {
      "name": "re-serialised-body",
      "path": "both",
      "about": "The same delivery after JSON.parse + JSON.stringify: the escapes became real characters, so the bytes differ and the signature no longer matches.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080,
        "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Created\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"Metadata\":{\"bookingId\":\"48213\",\"visitType\":\"\\u00C5terbes\\u00F6k\"}}}"
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=031fee715cf709d70f1c15e048d677fcc317bb52927f1127945e74a092f88399"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Created\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"Metadata\":{\"bookingId\":\"48213\",\"visitType\":\"Återbesök\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "tampered-body",
      "path": "both",
      "about": "Rivr signed a Pending body; someone changed it to Completed on the way.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080,
        "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Pending\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"Metadata\":{\"bookingId\":\"48213\"}}}"
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=ba5faf4268c9666ce86db4c3bb2291299715de3cc427ea9e0f6775770fb80b80"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "wrong-secret",
      "path": "both",
      "about": "Signed with a key you don't hold.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-X-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "secret-without-prefix",
      "path": "both",
      "about": "The whsec_ prefix is part of the key. Stripping it gives another key.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "empty-secret",
      "path": "both",
      "about": "Your secret is empty, such as a variable that is set but not filled in yet. Anyone can compute HMAC-SHA256 with an empty key, and this header was made that way, not by Rivr. Skip empty secrets.",
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=00a91d1ff09341be96f094542243caf36723e2d72f0ac3dd5fb65ca7f8050170"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        ""
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "edge-of-window",
      "path": "both",
      "about": "300 seconds old: still inside the window.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640380,
      "valid": true
    },
    {
      "name": "too-old",
      "path": "both",
      "about": "301 seconds old: outside the window, even though the signature matches.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640381,
      "valid": false
    },
    {
      "name": "from-the-future",
      "path": "both",
      "about": "301 seconds ahead of your clock: outside the window too.",
      "signing": {
        "keys": [
          "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
        ],
        "timestamp": 1789640080
      },
      "headers": {
        "Rivr-Signature": "t=1789640080,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789639779,
      "valid": false
    },
    {
      "name": "missing-header",
      "path": "callbackUrl",
      "about": "No Rivr-Signature at all. On the callbackUrl path that is what a delivery looks like when no signing key exists; decide on purpose what you do with it.",
      "headers": {},
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "no-timestamp",
      "path": "both",
      "about": "A v1 without t.",
      "headers": {
        "Rivr-Signature": "v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "bad-timestamp",
      "path": "both",
      "about": "t is not a number.",
      "headers": {
        "Rivr-Signature": "t=soon,v1=1e506d9e1195a98089bbb902f1a0b4c04fe39d39b50f33cf7f8f0e6c22c7ab0d"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    },
    {
      "name": "no-signature",
      "path": "both",
      "about": "t without any v1.",
      "headers": {
        "Rivr-Signature": "t=1789640080"
      },
      "body": "{\"id\":\"7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14\",\"type\":\"Order\",\"merchantId\":\"e26f9eeb-dd71-4f40-a508-d7344409430d\",\"status\":\"Completed\",\"data\":{\"CreatedDate\":\"2026-09-17T10:12:03Z\",\"Reference\":\"visit-48213\",\"CompletedDate\":\"2026-09-17T10:14:38Z\",\"PaymentMethod\":\"Swish\",\"Metadata\":{\"bookingId\":\"48213\"}}}",
      "secrets": [
        "whsec_TESTVEKTOR-A-pahittad-nyckel-inte-riktig-00"
      ],
      "now": 1789640092,
      "valid": false
    }
  ]
}
```

## 6. Deduplicate on Rivr-Webhook-Id

*Journal system*

Delivery is at least once. `Rivr-Webhook-Id` is the delivery's id: the same event to the same endpoint has the same id, on every retry too. Use it as your idempotency key.

Your organisation's endpoints and the clinic's path (`callbackUrl` and the clinic's own webhook address) send the header, the latter since 2026-10-21. The header is not part of the signature.

**What your system does:** Verify the signature first, because the id is not part of what is signed. Then store the id under a unique key in the same transaction as the work it triggers. When the same id comes again: answer `200` and do nothing.

> **Note.** The id is unique per endpoint. If you have both a `callbackUrl` and an organisation endpoint that receive the same order, the event arrives twice, by two paths and with different ids. The last line of defence is therefore still to act only on an actual status change.

## Common pitfalls

- Verifying against JSON that has been parsed and serialised again. `express.json()` and model binding in ASP.NET give you exactly that; read the body as bytes.
- Stripping `whsec_` from the secret. The prefix is part of the key.
- Letting an empty secret count as a key, such as an environment variable that is set but not filled in. Anyone can compute an HMAC with an empty key. The examples skip empty secrets, and the vector `empty-secret` tests it.
- Comparing only the first `v1`. During a rotation, and on the clinic's path where both the clinic and the platform can have keys, there are several, and yours can be any of them.
- Comparing with `==`. Use a constant-time comparison: `timingSafeEqual`, `hash_equals` or `hmac.compare_digest`.
- Answering `400` to a signature that does not match. Rivr reads `400` as a body that can never be accepted and does not retry the delivery. Answer `401`.
- A server clock that is off. The window is five minutes in both directions; sync the server with NTP.
- Trusting `Rivr-Webhook-Id` or the body before the signature is verified.

## Next guide

- [Receive webhooks](https://developers.rivr.io/guides/webhooks.md): The webhook is the truth about an order. How to receive it safely, and only once.
- [Payment at the clinic](https://developers.rivr.io/guides/pay-at-clinic.md): The patient checks out at reception and pays on the device before leaving.
