> ## Documentation Index
> Fetch the complete documentation index at: https://docs-consumer.voucher.allride.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifying signatures

> Check the x-signing-signature header of every webhook with HMAC-SHA256 and your two endpoint secrets.

Every webhook request is signed, so you can prove that it comes from AllRide and that the body was not changed on the way. Verify the signature on every request **before** you parse or act on the body, and reject requests that fail with `400`.

## Signature scheme

Each request carries one header:

```text theme={null}
x-signing-signature: t=<timestamp>,s1=<signature1>,s2=<signature2>
```

* `t` is the time of signing in **Unix seconds** (UTC).
* Each webhook endpoint has **two secrets**, `firstSecret` and `secondSecret`. `s1` is computed with the first secret and `s2` with the second.
* Each signature is the **lowercase hexadecimal HMAC-SHA256** of the string `<raw body>.<t>`: the exact request body bytes as received (before any JSON parsing or re-serialisation), a dot, then the value of `t`. The key is the secret's UTF-8 bytes.

A request is authentic when **either** `s1` or `s2` matches the HMAC computed with **either** of the secrets you have stored. Compare in constant time.

Protect against replayed requests by rejecting a `t` that is too far from your clock. We recommend a tolerance of **5 minutes**. Replays that you start from the delivery log are signed again with a fresh `t`, so they pass this check. Retries and replays keep the body's `event.id`, so deduplicate on it after the signature check.

Both secrets are shown only once: when you [create the endpoint](https://docs-consumer.voucher.allride.io/api-reference/partner-webhook-endpoints-create.md) (both) and when you rotate one of them (the new one). AllRide never shows them again, not in the dashboard and not in the delivery log, which records only the header value that was sent.

## Verify with a library

AllRide does not publish an SDK or a verification library. Every mainstream language ships HMAC-SHA256 and a constant-time comparison in its standard library, so the verification is about 20 lines; the handlers below need nothing else. If your web framework parses JSON before your code runs, configure the webhook route to give you the **raw body** (for example `express.raw({ type: "application/json" })` in Express, `request.get_data()` in Flask, `receiveText()` in Ktor): a body that was parsed and serialised again no longer matches the signature.

## Verify manually

Each handler below reads the raw body, verifies `s1` or `s2` against the secrets in the environment variables `WEBHOOK_FIRST_SECRET` and `WEBHOOK_SECOND_SECRET` (during a rotation one of them may be unset), answers `204` on success and `400` on failure. Process the event after you have acknowledged it.

```typescript Node.js (TypeScript) theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";

const SECRETS = [process.env.WEBHOOK_FIRST_SECRET, process.env.WEBHOOK_SECOND_SECRET].filter((s): s is string => Boolean(s));
const TOLERANCE_SECONDS = 300;

export function verify(rawBody: Buffer, header: string | undefined, secrets: string[] = SECRETS, now = Math.floor(Date.now() / 1000)) {
  const parts: Record<string, string> = Object.fromEntries(
    (header ?? "").split(",").map((p) => { const i = p.indexOf("="); return [p.slice(0, i).trim(), p.slice(i + 1).trim()]; }),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const received = [parts.s1, parts.s2].filter((s): s is string => /^[0-9a-f]{64}$/.test(s ?? ""));
  for (const secret of secrets) {
    const expected = createHmac("sha256", secret).update(Buffer.concat([rawBody, Buffer.from(`.${t}`)])).digest();
    for (const s of received) if (timingSafeEqual(expected, Buffer.from(s, "hex"))) return true;
  }
  return false;
}

createServer((req, res) => {
  const chunks: Buffer[] = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const rawBody = Buffer.concat(chunks); // the exact bytes, before any JSON parsing
    if (!verify(rawBody, req.headers["x-signing-signature"] as string | undefined)) { res.writeHead(400).end(); return; }
    const event = JSON.parse(rawBody.toString("utf8"));
    res.writeHead(204).end(); // acknowledge first, then process asynchronously
    setImmediate(() => console.log("received", event.event.type, event.event.id));
  });
}).listen(Number(process.env.PORT ?? 8080));
```

```python Python theme={null}
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRETS = [s for s in (os.environ.get("WEBHOOK_FIRST_SECRET"), os.environ.get("WEBHOOK_SECOND_SECRET")) if s]
TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str | None, secrets=SECRETS, now: int | None = None) -> bool:
    parts = dict(p.strip().split("=", 1) for p in (header or "").split(",") if "=" in p)
    try:
        t = int(parts.get("t", ""))
    except ValueError:
        return False
    now = int(time.time()) if now is None else now
    if abs(now - t) > TOLERANCE_SECONDS:
        return False
    received = [parts[k] for k in ("s1", "s2") if k in parts]
    for secret in secrets:
        expected = hmac.new(secret.encode(), raw_body + f".{t}".encode(), hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, s) for s in received):
            return True
    return False


class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers.get("Content-Length", 0)))  # exact bytes
        if not verify(raw_body, self.headers.get("x-signing-signature")):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw_body)
        self.send_response(204)  # acknowledge first, then process asynchronously
        self.end_headers()
        print("received", event["event"]["type"], event["event"]["id"])


if __name__ == "__main__":
    HTTPServer(("", int(os.environ.get("PORT", "8080"))), Handler).serve_forever()
```

```kotlin Kotlin theme={null}
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.security.MessageDigest
import java.util.HexFormat
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs

const val TOLERANCE_SECONDS = 300L

fun verify(rawBody: ByteArray, header: String?, secrets: List<String>, now: Long = System.currentTimeMillis() / 1000): Boolean {
    val parts = (header ?: "").split(",").mapNotNull { p ->
        p.split("=", limit = 2).takeIf { it.size == 2 }?.let { it[0].trim() to it[1].trim() }
    }.toMap()
    val t = parts["t"]?.toLongOrNull() ?: return false
    if (abs(now - t) > TOLERANCE_SECONDS) return false
    val received = listOfNotNull(parts["s1"], parts["s2"]).filter { it.matches(Regex("[0-9a-f]{64}")) }
    return secrets.any { secret ->
        val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256")) }
        mac.update(rawBody)
        val expected = mac.doFinal(".${parts["t"]}".toByteArray(Charsets.UTF_8))
        received.any { MessageDigest.isEqual(expected, HexFormat.of().parseHex(it)) }
    }
}

fun main() {
    val secrets = listOfNotNull(System.getenv("WEBHOOK_FIRST_SECRET"), System.getenv("WEBHOOK_SECOND_SECRET"))
    val server = HttpServer.create(InetSocketAddress(8080), 0)
    server.createContext("/") { exchange ->
        val rawBody = exchange.requestBody.readAllBytes() // the exact bytes, before any JSON parsing
        val ok = verify(rawBody, exchange.requestHeaders.getFirst("x-signing-signature"), secrets)
        exchange.sendResponseHeaders(if (ok) 204 else 400, -1) // acknowledge, then process asynchronously
        exchange.close()
    }
    server.start()
}
```

```java Java theme={null}
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

public class WebhookReceiver {
    static final long TOLERANCE_SECONDS = 300;

    static boolean verify(byte[] rawBody, String header, List<String> secrets, long now) throws Exception {
        Map<String, String> parts = new HashMap<>();
        for (String p : (header == null ? "" : header).split(",")) {
            int i = p.indexOf('=');
            if (i > 0) parts.put(p.substring(0, i).trim(), p.substring(i + 1).trim());
        }
        long t;
        try { t = Long.parseLong(parts.getOrDefault("t", "")); } catch (NumberFormatException e) { return false; }
        if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;
        byte[] suffix = ("." + parts.get("t")).getBytes(StandardCharsets.UTF_8);
        for (String secret : secrets) {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update(rawBody);
            byte[] expected = mac.doFinal(suffix);
            for (String key : List.of("s1", "s2")) {
                String s = parts.get(key);
                if (s == null || !s.matches("[0-9a-f]{64}")) continue;
                if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(s))) return true;
            }
        }
        return false;
    }

    public static void main(String[] args) throws Exception {
        List<String> secrets = java.util.stream.Stream.of(System.getenv("WEBHOOK_FIRST_SECRET"), System.getenv("WEBHOOK_SECOND_SECRET"))
            .filter(Objects::nonNull).toList();
        HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
        server.createContext("/", exchange -> {
            byte[] rawBody = exchange.getRequestBody().readAllBytes(); // the exact bytes
            boolean ok;
            try {
                ok = verify(rawBody, exchange.getRequestHeaders().getFirst("x-signing-signature"), secrets, System.currentTimeMillis() / 1000);
            } catch (Exception e) {
                ok = false;
            }
            exchange.sendResponseHeaders(ok ? 204 : 400, -1); // acknowledge, then process asynchronously
            exchange.close();
        });
        server.start();
    }
}
```

```go Go theme={null}
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"io"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

const toleranceSeconds = 300

func verify(rawBody []byte, header string, secrets []string, now int64) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(strings.TrimSpace(p), "="); ok {
			parts[k] = v
		}
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || now-t > toleranceSeconds || t-now > toleranceSeconds {
		return false
	}
	for _, secret := range secrets {
		if secret == "" {
			continue
		}
		mac := hmac.New(sha256.New, []byte(secret))
		mac.Write(rawBody)
		mac.Write([]byte("." + parts["t"]))
		expected := mac.Sum(nil)
		for _, s := range []string{parts["s1"], parts["s2"]} {
			if got, err := hex.DecodeString(s); err == nil && hmac.Equal(expected, got) {
				return true
			}
		}
	}
	return false
}

func main() {
	secrets := []string{os.Getenv("WEBHOOK_FIRST_SECRET"), os.Getenv("WEBHOOK_SECOND_SECRET")}
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		rawBody, err := io.ReadAll(r.Body) // the exact bytes, before any JSON parsing
		if err != nil || !verify(rawBody, r.Header.Get("x-signing-signature"), secrets, time.Now().Unix()) {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		w.WriteHeader(http.StatusNoContent) // acknowledge, then process rawBody asynchronously
	})
	http.ListenAndServe(":8080", nil)
}
```

```php PHP theme={null}
<?php
// Webhook receiver: verifies x-signing-signature, answers 204 or 400.
const TOLERANCE_SECONDS = 300;

function verify_signature(string $rawBody, ?string $header, array $secrets, ?int $now = null): bool
{
    $parts = [];
    foreach (explode(',', $header ?? '') as $part) {
        $kv = explode('=', trim($part), 2);
        if (count($kv) === 2) {
            $parts[$kv[0]] = $kv[1];
        }
    }
    if (!isset($parts['t']) || !ctype_digit($parts['t'])) {
        return false;
    }
    $t = (int) $parts['t'];
    if (abs(($now ?? time()) - $t) > TOLERANCE_SECONDS) {
        return false;
    }
    foreach ($secrets as $secret) {
        $expected = hash_hmac('sha256', $rawBody . '.' . $parts['t'], $secret);
        foreach (['s1', 's2'] as $key) {
            if (isset($parts[$key]) && hash_equals($expected, $parts[$key])) {
                return true;
            }
        }
    }
    return false;
}

$secrets = array_values(array_filter([getenv('WEBHOOK_FIRST_SECRET'), getenv('WEBHOOK_SECOND_SECRET')]));
$rawBody = file_get_contents('php://input'); // the exact bytes, before json_decode
if (!verify_signature($rawBody, $_SERVER['HTTP_X_SIGNING_SIGNATURE'] ?? null, $secrets)) {
    http_response_code(400);
    exit;
}
$event = json_decode($rawBody, true);
http_response_code(204); // acknowledge, then hand $event to your queue
```

To compute a signature by hand, for example while debugging, use `openssl` on a file that holds the exact body:

```bash theme={null}
T=1793610900   # the t value of the header
{ cat body.json; printf '.%s' "$T"; } | openssl dgst -sha256 -hmac "$WEBHOOK_FIRST_SECRET" | awk '{print $NF}'
```

The output must equal `s1` (and the same command with `WEBHOOK_SECOND_SECRET` must equal `s2`). Make sure the file has no trailing newline that was not in the request.

## Test vector

Use these documentation values to test your implementation. They are not real secrets.

| Input | Value |
| - | - |
| First secret | `doc-first-secret-0123456789abcdef` |
| Second secret | `doc-second-secret-0123456789abcde` |
| `t` | `1793610900` (2026-11-02T09:15:00Z) |

Raw body (1476 bytes, UTF-8, no trailing newline; the two `€` signs are multi-byte characters):

```text theme={null}
{"event":{"id":"3f1c9a52-7b4e-4d21-9c6a-0e5d8b2f4a17","type":"redemption-created","timestamp":1793610900},"payload":{"resource":"redemption","id":6017,"status":"completed","channel":"api","providerPartnerId":1030,"voucherSetId":2030,"voucherSetRevisionNo":2,"voucherId":5017,"voucherSet":{"id":2030,"name":{"de":"5 € Fahrguthaben","en":"€5 ride credit"},"providerDisplayName":{"de":"Acme Scooters","en":"Acme Scooters"}},"issued":{"kind":"code","code":"ACME5-7KQ2-M9XA","link":"https://acme-scooters.example.com/r?c=ACME5-7KQ2-M9XA"},"validFrom":null,"validUntil":"2026-12-31T22:59:59Z","redemptionEntity":{"id":9017,"resource":"redemption-entity","refId":"customer:48213","_links":{"self":{"href":"https://api.voucher.allride.io/consumer/v1/redemption-entities/9017","type":"application/json"}}},"redemptionLink":null,"batchId":null,"metadata":{"redemptionReason":"standard","forMonth":"2026-10"},"actor":{"type":"api-key","id":12017,"label":"Production integration"},"idempotencyKey":"order-8812:line-1","legacyRef":null,"redeemedAt":"2026-10-21T17:42:10.120Z","voidedAt":null,"voidedBy":null,"voidReason":null,"_links":{"self":{"href":"https://api.voucher.allride.io/consumer/v1/redemptions/6017","type":"application/json"},"voucherSet":{"href":"https://api.voucher.allride.io/consumer/v1/catalog/voucher-sets/2030","type":"application/json"},"redemptionEntity":{"href":"https://api.voucher.allride.io/consumer/v1/redemption-entities/9017","type":"application/json"}}}}
```

Expected header:

```text theme={null}
x-signing-signature: t=1793610900,s1=698ac9c68e40a2f0a3bfa20d395821aad080e36ba222d328ed95bbba1d7acbd4,s2=d437486353077d2fd731197c10a2ea2c5279a4274eff0b81beff3ddbd92331f4
```

A correct implementation:

* accepts the request with both secrets configured;
* accepts it with only the second secret configured (the situation during a rotation of the first secret);
* rejects it when one byte of the body changes;
* rejects it when your clock is more than 5 minutes away from `t` (set "now" to `1793610900` when you run the vector).

Check your handler against this vector before you go live: it must accept the request, and reject it when you change one byte of the body.

## Secret rotation

The two secrets let you rotate without dropping a single webhook, because AllRide signs every request with both:

**1. Rotate the first secret**

Call [Rotate the first secret](https://docs-consumer.voucher.allride.io/api-reference/partner-webhook-endpoints-rotate-first-secret.md) (or use the developer dashboard). The response shows the new `firstSecret` once. From now on, `s1` uses the new secret and `s2` still uses the unchanged second secret, so your receiver keeps accepting requests through `s2`.

**2. Update your receiver**

Store the new first secret and deploy. Your receiver now accepts `s1` again.

**3. Rotate the second secret**

Call [Rotate the second secret](https://docs-consumer.voucher.allride.io/api-reference/partner-webhook-endpoints-rotate-second-secret.md), store the new `secondSecret` and deploy again.

Rotation needs `manage-webhooks` (and a recent sign-in for dashboard users). If a rotation response gets lost, a retry with the same `Idempotency-Key` returns the endpoint with the secret set to `null`; rotate that slot again. Rotate immediately if you suspect a secret has leaked.

## Related

* [Webhooks](https://docs-consumer.voucher.allride.io/webhooks/overview.md)
* [Retries, logs and replay](https://docs-consumer.voucher.allride.io/webhooks/retries-and-logs.md)
* [Event catalog](https://docs-consumer.voucher.allride.io/webhooks/events.md)
* [Rotate the first secret](https://docs-consumer.voucher.allride.io/api-reference/partner-webhook-endpoints-rotate-first-secret.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.