# Receive webhooks

> The webhook is the truth about an order. How to receive it safely, and only once.

A Rivr order is not a terminal that answers yes or no on the spot. The patient may pay in two minutes or in a month. So Rivr is the one who calls when something decisive has happened — with a webhook. **Receiving webhooks is mandatory.**

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

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. Tell Rivr where to send it

*Journal system → Rivr*

Send `callbackUrl` on every order you create. Without it, Rivr uses the address configured for the clinic or the platform — and if there is none, no webhooks are sent at all on that path.

Your organisation's webhook endpoints are a path of their own. An order that an organisation above the clinic creates with its own API key does not use the clinic's configured address: it goes to the organisation's endpoints, and to `callbackUrl` if the order has one. A key that belongs to the clinic itself keeps the clinic's address.

**Why:** The address is stored on the order. When you send it yourself you always know where an order's events go — even when test, staging and production have different receivers.

**What your system does:** Use HTTPS. For `callbackUrl` Rivr does not check the scheme, but the webhook carries orders and amounts. Your organisation's endpoints must be https.

## 2. Rivr sends a POST

*Rivr → Journal system*

A webhook is sent when the order is created and when it becomes `Pending`, `Completed`, `Cancelled` or `Refunded`. It carries the order id, the clinic's `merchantId` and **the order's current status**.

Top-level fields are camelCase. The contents of `data` are PascalCase and grow with the status: `CompletedDate` and `PaymentMethod` on Completed, `CancelledDate` and `Reason` on Cancelled, `RefundedDate` on Refunded.

> **Note.** In the webhook `status` is a **string**. When you fetch the order with `GET`, `status` is a **number**: 1 Created, 2 Pending, 4 Completed, 8 Refunded, 32 Cancelled.

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

```http
POST /webhooks/rivr
Content-Type: application/json
Rivr-Signature: t=1789640080,v1=5f2b0c…

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

**In the portal:** [Every event, its fields and the delivery paths](https://developers.rivr.io/events)

## 3. Verify the signature

*Journal system*

The `Rivr-Signature` header looks like `t=<unix seconds>,v1=<hex>`. Compute HMAC-SHA256 over `{t}.{raw body}` with the signing secret and compare with every `v1` — there is one per active key, so keys can rotate without downtime.

**Why:** Your receiver's address is no secret. Without the signature anyone could post `"status": "Completed"` and make an unpaid treatment look paid.

> **Warning.** The key is the **whole** secret, `whsec_` prefix included. Sign the **raw** body — JSON that has been parsed and serialised again will not match. Rivr creates the signing key for your platform; without a key the webhook is sent unsigned.

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

**In the portal:** [Test vectors and both delivery paths](https://developers.rivr.io/guides/verify-signatures)

## 4. Answer 2xx right away

*Journal system → Rivr*

Rivr waits **5 seconds**. If Rivr gets no 2xx answer in time, the delivery counts as failed and is sent again: after 1 minute, 5 minutes, 30 minutes, 1 hour, 4 hours, 12 hours and then once a day for seven days, counted from when the webhook was created.

The exceptions are `400`, `405`, `410`, `413`, `414`, `415` and `422`. They mean the body can never be accepted, and Rivr gives up at once. So answer `401`, not `400`, when the signature does not match.

**What your system does:** Verify, put the event on a queue and answer `200`. Do the actual work — update the booking, post the entry — afterwards.

**In the portal:** [See deliveries and response codes](https://developers.rivr.io/webhooks)

## 5. Handle duplicates and ordering

*Journal system*

Delivery is **at least once** and **without an ordering guarantee**. The same event can arrive twice, and `Completed` can arrive before `Created` if the first delivery failed. Every delivery carries `Rivr-Webhook-Id`, on the `callbackUrl` path and to your organisation's endpoints alike: the same event has the same id on every retry. Use it as your idempotency key (see the guide Verify the webhook signature).

**What your system does:** Make the handling idempotent: fetch the order with `GET /api/public/orders/{id}` and move the journal to the order's status only if it differs from what the journal already shows. A duplicate then does nothing — and an order that was reopened and paid again still counts.

**Request:** `GET /api/public/orders/7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14` · Merchant token · [Reference](https://developers.rivr.io/reference/api-public/getOrder.md)

```bash
curl -X GET https://api.test.rivr.io/api/public/orders/7b1e7c3a-2f4d-4a51-9c7e-5d0a6b8e2c14 \
  -H "Authorization: Bearer $MERCHANT_TOKEN"
```

## Common pitfalls

- Updating the journal inside the request that receives the webhook. If it takes more than 5 seconds the webhook is sent again — and again.
- Verifying the signature against re-serialised JSON, or with the secret stripped of its `whsec_` prefix.
- Reading `status` as a string in the API response. There it is a number.
- Expecting `GET` on an unknown order to return `404`. It returns `204` with no body.
- Assuming a new `PUT` on an existing order is silent. If the order isn't paid, the call rewrites it and opens it again, and the `Created` webhook comes once more. A paid order is left as it is and sends no webhook.
- Deduplicating permanently on order id and status. An order that is reopened and paid again sends `Completed` once more, and that one should count.

## Next guide

- [Verify the webhook signature](https://developers.rivr.io/guides/verify-signatures.md): Code in Node, PHP, Python and .NET, pseudocode for Delphi and 4D, and test vectors to prove your own code against.
- [The webhook didn't arrive](https://developers.rivr.io/guides/webhook-troubleshooting.md): A decision tree for a missing webhook: which path it should take, whether it was sent at all, why the delivery fails and what to do once Rivr has given up.
- [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.
- [Payment on the phone](https://developers.rivr.io/guides/pay-by-link.md): The patient gets a payment link by SMS and pays whenever it suits them.
