<!-- Carebit docs: Webhook signatures -->

# Verifying webhook signatures

Every webhook delivery carries a single `Carebit-Signature` header. There
is no separate `Carebit-Timestamp` header - the timestamp is inside
`Carebit-Signature`.

The header is a comma-separated list of `key=value` pairs:

```
Carebit-Signature: t=1755428400,v1=5a1c...abcd,v1=9f2e...beef
```

- `t=` is the unix timestamp of the signature.
- `v1=` is an HMAC-SHA256 hex digest.

There may be more than one `v1=` value. During a 24-hour rotation window
after you rotate an endpoint's signing secret, Carebit signs every
request with the new secret AND the previous secret so a receiver that
has both configured can accept either.

## Steps

1. Parse the header. Extract `t` and every `v1=` value into a list.
2. Compute the signed payload string. It is the timestamp, a literal
   period, then the **exact raw request body bytes**. Do not
   re-serialize the JSON; use the raw bytes as received.
3. Compute `HMAC-SHA256(secret, signed_payload)` using each of your
   endpoint's active signing secrets. Compare each expected digest
   against every `v1=` value in the header using a constant-time
   comparison. If any `v1=` matches any active secret, the signature is
   valid.
4. Reject the request if `t` is more than 5 minutes older than your
   server clock, or more than 5 minutes in the future.

Capture the raw body **before** any JSON parser touches it. A single
whitespace difference will invalidate the signature.

<!-- code-tabs -->

### Node.js

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

// activeSecrets is [currentSecret, previousSecret?] read from your config.
// Returns false for any malformed input; never throws.
export function verifyCarebitSignature({
  header,
  rawBody,
  activeSecrets,
  toleranceSeconds = 300,
}) {
  if (typeof header !== "string" || header.length === 0) {
    return false;
  }
  const pairs = header.split(",").map((pair) => pair.trim().split("=", 2));
  const timestampPair = pairs.find(([key]) => key === "t");
  const timestampRaw = timestampPair?.[1];
  if (typeof timestampRaw !== "string" || !/^\d{1,15}$/.test(timestampRaw)) {
    return false;
  }
  const timestamp = Number(timestampRaw);
  if (!Number.isFinite(timestamp)) {
    return false;
  }
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) {
    return false;
  }
  const signatures = pairs
    .filter(
      ([key, value]) =>
        key === "v1" &&
        typeof value === "string" &&
        /^[0-9a-f]{64}$/i.test(value),
    )
    .map(([, value]) => value.toLowerCase());
  if (signatures.length === 0) {
    return false;
  }
  const bodyBuffer = Buffer.isBuffer(rawBody)
    ? rawBody
    : Buffer.from(String(rawBody), "utf8");
  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`, "utf8"),
    bodyBuffer,
  ]);
  return activeSecrets.some((secret) => {
    if (typeof secret !== "string" || secret.length === 0) {
      return false;
    }
    const expected = crypto
      .createHmac("sha256", secret)
      .update(signedPayload)
      .digest("hex");
    const expectedBytes = Buffer.from(expected, "hex");
    return signatures.some((provided) => {
      const providedBytes = Buffer.from(provided, "hex");
      if (providedBytes.length !== expectedBytes.length) {
        return false;
      }
      return crypto.timingSafeEqual(providedBytes, expectedBytes);
    });
  });
}
```

### Python

```python
import hmac
import time
from hashlib import sha256


def verify_carebit_signature(header: str, raw_body: bytes, active_secrets: list[str], tolerance_seconds: int = 300) -> bool:
    if not isinstance(header, str) or not header:
        return False
    pairs = [pair.strip().split("=", 1) for pair in header.split(",") if "=" in pair]
    timestamp_raw = next((value for key, value in pairs if key == "t"), None)
    if not timestamp_raw or len(timestamp_raw) > 15 or not timestamp_raw.isdigit():
        return False
    timestamp = int(timestamp_raw)
    if abs(int(time.time()) - timestamp) > tolerance_seconds:
        return False
    signatures = [value.lower() for key, value in pairs if key == "v1" and len(value) == 64]
    if not signatures:
        return False
    signed_payload = f"{timestamp}.".encode() + raw_body
    for secret in active_secrets:
        if not isinstance(secret, str) or not secret:
            continue
        expected = hmac.new(secret.encode(), signed_payload, sha256).hexdigest()
        for provided in signatures:
            if hmac.compare_digest(expected, provided):
                return True
    return False
```

### Ruby

```ruby
require "openssl"

# active_secrets is an array like [current_secret, previous_secret] read from your config.
# Returns false for any malformed input; never raises.
def verify_carebit_signature(header:, raw_body:, active_secrets:, tolerance_seconds: 300)
  return false unless header.is_a?(String) && !header.empty?

  pairs = header.split(",").map { |pair| pair.strip.split("=", 2) }
  timestamp_raw = pairs.find { |key, _| key == "t" }&.last
  return false unless timestamp_raw.is_a?(String) && /\A\d{1,15}\z/.match?(timestamp_raw)

  timestamp = timestamp_raw.to_i
  return false if (Time.now.to_i - timestamp).abs > tolerance_seconds

  signatures = pairs
    .select { |key, value| key == "v1" && value.is_a?(String) && /\A[0-9a-f]{64}\z/i.match?(value) }
    .map { |(_, value)| value.downcase }
  return false if signatures.empty?

  signed_payload = "#{timestamp}.#{raw_body}"
  active_secrets.any? do |secret|
    next false unless secret.is_a?(String) && !secret.empty?

    expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed_payload)
    signatures.any? do |provided|
      next false if provided.bytesize != expected.bytesize

      OpenSSL.fixed_length_secure_compare(provided, expected)
    end
  end
end
```

### PHP

```php
<?php

// $activeSecrets is an array like [$currentSecret, $previousSecret].
// $rawBody must be the exact bytes of the request body, before any parser.
function verify_carebit_signature(string $header, string $rawBody, array $activeSecrets, int $toleranceSeconds = 300): bool {
    if ($header === '') {
        return false;
    }
    $pairs = [];
    foreach (explode(',', $header) as $part) {
        $trimmed = trim($part);
        $split = explode('=', $trimmed, 2);
        if (count($split) === 2) {
            $pairs[] = $split;
        }
    }
    $timestampRaw = null;
    foreach ($pairs as [$key, $value]) {
        if ($key === 't') {
            $timestampRaw = $value;
            break;
        }
    }
    if ($timestampRaw === null || !preg_match('/^\d{1,15}$/', $timestampRaw)) {
        return false;
    }
    $timestamp = (int) $timestampRaw;
    if (abs(time() - $timestamp) > $toleranceSeconds) {
        return false;
    }
    $signatures = [];
    foreach ($pairs as [$key, $value]) {
        if ($key === 'v1' && is_string($value) && preg_match('/^[0-9a-f]{64}$/i', $value)) {
            $signatures[] = strtolower($value);
        }
    }
    if (empty($signatures)) {
        return false;
    }
    $signedPayload = $timestampRaw . '.' . $rawBody;
    foreach ($activeSecrets as $secret) {
        if (!is_string($secret) || $secret === '') {
            continue;
        }
        $expected = hash_hmac('sha256', $signedPayload, $secret);
        foreach ($signatures as $provided) {
            if (hash_equals($expected, $provided)) {
                return true;
            }
        }
    }
    return false;
}
```

<!-- /code-tabs -->

## Deterministic test vectors

The published OpenAPI document under `x-carebit-signature-vectors`
contains three deterministic vectors: `primary`, `rotation`, and
`stale_timestamp_rejected`. Every reference implementation above
produces identical digests against those vectors.
