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

# Verifying signatures

> Check that a webhook comes from AllRide and was not changed.

## Signature scheme

Every delivery carries the header:

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

* `t` is the Unix time in seconds when this attempt was sent.
* Each endpoint has **two secrets**. `s1` is computed with the first secret and `s2` with the second.
* Each signature is the lower-case hex HMAC-SHA256 of `<raw body>.<t>`: the exact request bytes before any JSON parsing, a dot, then the value of `t`.

Accept the request if **either** signature matches **either** of the secrets you stored, using a constant-time comparison. Reject it otherwise (answer 400 or 401). This is what makes rotation safe: AllRide always signs with both live secrets.

Replay protection is yours to choose: reject a `t` more than 5 minutes away from your clock. Deduplicate on the body's `event.id`, because retries and replays reuse it with a fresh `t`.

Secrets are shown once, when you create the endpoint and when you rotate a secret.

## No library needed

No library is needed: the scheme is an HMAC-SHA256 that every platform has. There are no official SDKs in v1; the samples below are complete verifiers you can copy. They read the secrets from the environment variables `WEBHOOK_FIRST_SECRET` and `WEBHOOK_SECOND_SECRET` (during a rotation one of them may be unset) and take an optional `now` (Unix seconds) so you can run the [test vector](#test-vector). Two rules matter in every language:

* Read the **raw body** bytes. Re-serialising parsed JSON changes the bytes and breaks the signature.
* Compare in constant time.

## Verify manually

```javascript Node.js theme={null}
import crypto from "node:crypto";
import express from "express";

const SECRETS = [process.env.WEBHOOK_FIRST_SECRET, process.env.WEBHOOK_SECOND_SECRET].filter(Boolean);
const TOLERANCE_SECONDS = 300;

export function verify(rawBody, header, now = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(now - t) > TOLERANCE_SECONDS) return false;
  const given = [parts.s1, parts.s2].filter(Boolean).map((s) => Buffer.from(s, "hex"));
  return SECRETS.some((secret) => {
    const expected = crypto.createHmac("sha256", secret).update(Buffer.concat([rawBody, Buffer.from(`.${parts.t}`)])).digest();
    return given.some((g) => g.length === expected.length && crypto.timingSafeEqual(g, expected));
  });
}

const app = express();
app.post("/allride-voucher", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req.body, req.get("x-signing-signature") ?? "")) return res.sendStatus(400);
  // store JSON.parse(req.body) and process it asynchronously; deduplicate on its event.id
  res.sendStatus(204);
});
app.listen(8443);
```

```python Python theme={null}
import hashlib, hmac, os, time
from flask import Flask, request

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

def verify(raw_body: bytes, header: str, now: int | None = None) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    now = int(time.time()) if now is None else now
    if not t.isdigit() or abs(now - int(t)) > TOLERANCE_SECONDS:
        return False
    given = [parts[k] for k in ("s1", "s2") if k in parts]
    for secret in SECRETS:
        expected = hmac.new(secret, raw_body + b"." + t.encode(), hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, g) for g in given):
            return True
    return False

@app.post("/allride-voucher")
def receive():
    if not verify(request.get_data(), request.headers.get("x-signing-signature", "")):
        return "", 400
    # store request.get_json() and process it asynchronously; deduplicate on its event.id
    return "", 204
```

```kotlin Kotlin theme={null}
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import kotlin.math.abs

class WebhookVerifier(private val secrets: List<String>, private val toleranceSeconds: Long = 300) {
    fun verify(rawBody: ByteArray, header: 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"]?.takeIf { it.all(Char::isDigit) }?.toLongOrNull() ?: return false
        if (abs(now - t) > toleranceSeconds) return false
        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)).joinToString("") { "%02x".format(it) }
            listOfNotNull(parts["s1"], parts["s2"]).any { MessageDigest.isEqual(expected.toByteArray(), it.toByteArray()) }
        }
    }
}

// Ktor: post("/allride-voucher") {
//     val rawBody = call.receive<ByteArray>() // the exact bytes, before any JSON parsing
//     if (!verifier.verify(rawBody, call.request.header("x-signing-signature"))) return@post call.respond(HttpStatusCode.BadRequest)
//     // store the body and process it asynchronously; deduplicate on its event.id
//     call.respond(HttpStatusCode.NoContent)
// }
```

```java Java theme={null}
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;

public final class WebhookVerifier {
    private static final long TOLERANCE_SECONDS = 300;
    private final List<String> secrets;

    public WebhookVerifier(List<String> secrets) { this.secrets = secrets; }

    public boolean verify(byte[] rawBody, String header) throws Exception {
        return verify(rawBody, header, System.currentTimeMillis() / 1000);
    }

    public boolean verify(byte[] rawBody, String header, long now) throws Exception {
        Map<String, String> parts = new HashMap<>();
        for (String p : header.split(",")) { String[] kv = p.split("=", 2); if (kv.length == 2) parts.put(kv[0], kv[1]); }
        String t = parts.get("t");
        if (t == null || !t.matches("\\d+") || Math.abs(now - Long.parseLong(t)) > TOLERANCE_SECONDS) return false;
        byte[] signed = concat(rawBody, ("." + t).getBytes(StandardCharsets.UTF_8));
        for (String secret : secrets) {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            byte[] expected = HexFormat.of().formatHex(mac.doFinal(signed)).getBytes(StandardCharsets.US_ASCII);
            for (String key : List.of("s1", "s2")) {
                String given = parts.get(key);
                if (given != null && MessageDigest.isEqual(expected, given.getBytes(StandardCharsets.US_ASCII))) return true;
            }
        }
        return false;
    }

    private static byte[] concat(byte[] a, byte[] b) {
        byte[] out = Arrays.copyOf(a, a.length + b.length);
        System.arraycopy(b, 0, out, a.length, b.length);
        return out;
    }
}
```

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

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

var secrets = []string{os.Getenv("WEBHOOK_FIRST_SECRET"), os.Getenv("WEBHOOK_SECOND_SECRET")}

func Verify(rawBody []byte, header string, now int64) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if kv := strings.SplitN(p, "=", 2); len(kv) == 2 {
			parts[kv[0]] = kv[1]
		}
	}
	t, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || now-t > 300 || t-now > 300 {
		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 := []byte(hex.EncodeToString(mac.Sum(nil)))
		for _, k := range []string{"s1", "s2"} {
			if g, ok := parts[k]; ok && hmac.Equal(expected, []byte(g)) {
				return true
			}
		}
	}
	return false
}

func Handler(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	if !Verify(body, r.Header.Get("x-signing-signature"), time.Now().Unix()) {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	// store body and process it asynchronously; deduplicate on its event.id
	w.WriteHeader(http.StatusNoContent)
}
```

```php PHP theme={null}
<?php
$secrets = array_filter([getenv('WEBHOOK_FIRST_SECRET'), getenv('WEBHOOK_SECOND_SECRET')]);
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_SIGNING_SIGNATURE'] ?? '';

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

if (!verify($raw, $header, $secrets)) { http_response_code(400); exit; }
// store json_decode($raw, true) and process it asynchronously; deduplicate on its event.id
http_response_code(204);
```

## Test vector

Use these documentation values to test your verifier. `t` is in the past, so pass `now` = `1792656000` (the vector's `t`) when you run it.

| Input | Value |
| - | - |
| First secret | `doc-first-secret-0123456789abcdef` |
| Second secret | `doc-second-secret-0123456789abcde` |
| `t` | `1792656000` |

Raw body (403 bytes, UTF-8, one line, no trailing newline):

```text theme={null}
{"event":{"id":"8d2e4b61-5a3f-4c7e-9b10-2f6a7c8d9e01","type":"redemption-voided","timestamp":1792656000},"payload":{"resource":"redemption","id":6030,"status":"voided","voucherSetId":2030,"voucherId":5030,"redeemedAt":"2026-10-21T17:42:10.120Z","voidedAt":"2026-10-22T08:00:00Z","_links":{"self":{"href":"https://api.voucher-staging.allride.io/provider/v1/redemptions/6030","type":"application/json"}}}}
```

Expected header:

```text theme={null}
x-signing-signature: t=1792656000,s1=b5a68283cd75a7651d81ba23e895c2eb6eb3a7c63b89869fc835113361e481b9,s2=a65bf6dd54e602e5653b2de758e8cbb29d8851606eff23536fb52e2646fb5a2a
```

Your verifier must accept it with both secrets configured, accept it with only the second secret configured, and reject it when one byte of the body changes.

## Secret rotation

Rotate one secret at a time, so every delivery stays verifiable:

1. Call `POST /partner/v1/webhook-endpoints/{webhookEndpointId}:rotate-first-secret` (dashboard users need a recent sign-in). The response contains the new `firstSecret`, once.
2. Update your receiver to accept the new first secret and the unchanged second secret. Deliveries in between still verify through `s2`.
3. Later, rotate the second secret the same way with `:rotate-second-secret`.

If a secret leaked, rotate it at once and check your delivery log.

## Related

* [Webhooks](https://docs-provider.voucher.allride.io/webhooks/overview.md)
* [Retries, logs and replay](https://docs-provider.voucher.allride.io/webhooks/retries-and-logs.md)
* [Event catalog](https://docs-provider.voucher.allride.io/webhooks/events.md)


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