# Verifying deliveries

Source: https://solidhooks.com/docs/delivery-signatures

> The headers SolidHooks signs each delivery with, and how to verify them in your endpoint before trusting the payload.

Every delivery SolidHooks sends to your endpoint is signed with the destination's signing secret (`whsec_...`). Verify the signature before you act on the payload. It proves the request came from SolidHooks and that the body was not altered on the way.

This page is about deliveries going out. For verifying webhooks coming in from a provider, see [Verification](https://solidhooks.com/docs/verification).

## Two schemes

Each destination uses one of two signature schemes. `signatureScheme` on the destination tells you which.

| Scheme              | Default for                                                      | Headers                                                |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------ |
| `standard_webhooks` | New destinations                                                 | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| `solidhooks_v1`     | Destinations created before Standard Webhooks became the default | `x-solidhooks-signature`, `x-solidhooks-timestamp`     |

`solidhooks_v1` is supported indefinitely. Standard Webhooks is the right choice for almost everything, because any [Standard Webhooks](https://www.standardwebhooks.com/) library verifies it in one call.

### Standard Webhooks

```text
webhook-id:        dlv_...        the delivery id
webhook-timestamp: 1755300000     unix seconds
webhook-signature: v1,<base64>    space-delimited list, may hold more than one value
```

To verify:

1. Build the signed content as `<webhook-id>.<webhook-timestamp>.<raw body>`.
2. Compute the HMAC-SHA256 of it, base64-encoded. The key is the secret with the `whsec_` prefix removed and the rest base64-decoded. Use the decoded bytes, not the literal string.
3. Compare it, in constant time, against each entry in `webhook-signature`. Accept the request if any entry matches.

`webhook-signature` is a list, so never treat the first entry as authoritative. During a [rotation overlap](https://solidhooks.com/docs/secret-rotation) it carries signatures for both the new and the previous secret, and you can adopt the new secret on your own schedule.

### solidhooks_v1

```text
x-solidhooks-signature: <hex>
x-solidhooks-timestamp: 1755300000     unix seconds
```

To verify, compute the hex HMAC-SHA256 of `<x-solidhooks-timestamp>.<raw body>` and compare it in constant time to `x-solidhooks-signature`. The key is the whole `whsec_...` string exactly as written. It is not base64-decoded. The header carries a single value, so this scheme cannot express a rotation overlap.

## Verification code

Both samples verify the raw body bytes and reject timestamps more than five minutes from now.

**Node.js**

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

function safeEqual(a: string, b: string): boolean {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

function timestampIsFresh(timestamp: string): boolean {
  const seconds = Number(timestamp);
  return Number.isInteger(seconds) && Math.abs(Date.now() / 1000 - seconds) <= TOLERANCE_SECONDS;
}

// Standard Webhooks. `rawBody` is the exact bytes received, as a Buffer.
export function verifyStandardWebhooks(
  secret: string, // whsec_...
  headers: Record<string, string | undefined>,
  rawBody: Buffer
): boolean {
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = headers['webhook-signature'];
  if (!id || !timestamp || !signatures || !timestampIsFresh(timestamp)) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), rawBody]);
  const expected = `v1,${createHmac('sha256', key).update(signed).digest('base64')}`;

  return signatures.split(' ').some((entry) => safeEqual(entry, expected));
}

// solidhooks_v1
export function verifySolidhooksV1(
  secret: string, // whsec_..., used as-is
  headers: Record<string, string | undefined>,
  rawBody: Buffer
): boolean {
  const signature = headers['x-solidhooks-signature'];
  const timestamp = headers['x-solidhooks-timestamp'];
  if (!signature || !timestamp || !timestampIsFresh(timestamp)) return false;

  const signed = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
  const expected = createHmac('sha256', secret).update(signed).digest('hex');

  return safeEqual(signature, expected);
}
```

**Python**

```python
import base64
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300

def _fresh(timestamp: str) -> bool:
    try:
        return abs(time.time() - int(timestamp)) <= TOLERANCE_SECONDS
    except ValueError:
        return False

def verify_standard_webhooks(secret: str, headers: dict, raw_body: bytes) -> bool:
    """`secret` is whsec_...; `headers` keys are lowercase; `raw_body` is the exact bytes received."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not (msg_id and timestamp and signatures and _fresh(timestamp)):
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    digest = hmac.new(key, signed, hashlib.sha256).digest()
    expected = "v1," + base64.b64encode(digest).decode()

    return any(hmac.compare_digest(entry, expected) for entry in signatures.split(" "))

def verify_solidhooks_v1(secret: str, headers: dict, raw_body: bytes) -> bool:
    """`secret` is whsec_..., used as-is (not base64-decoded)."""
    signature = headers.get("x-solidhooks-signature")
    timestamp = headers.get("x-solidhooks-timestamp")
    if not (signature and timestamp and _fresh(timestamp)):
        return False

    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()

    return hmac.compare_digest(signature, expected)
```

## Rules that apply to both schemes

* **Use the raw body.** Verify against the exact bytes you received, before any JSON parsing. Re-serialising changes the bytes and the signature will not match.
* **Check the timestamp.** Reject anything outside your tolerance window to block replays. Five minutes is typical.
* **Deduplicate on `webhook-id`.** Under Standard Webhooks it is the delivery id, which stays the same across retries of one delivery, so use it as your idempotency key. A delivery can reach you more than once.
* **Treat a failed check as a failed delivery.** Return a non-2xx status and SolidHooks will [retry](https://solidhooks.com/docs/retries).

## Get the signing secret

```bash
curl -s "$API/v1/destinations/dest_.../signing-secret" \
  -H "authorization: Bearer $SDHK_KEY"
```

The secret is `whsec_` followed by standard base64 of 32 random bytes. Reading it needs an API key scoped `destination: read & write`. A key without that grant gets `403 INSUFFICIENT_API_KEY_PERMISSIONS`.

## Switch scheme

```bash
curl -s "$API/v1/destinations/dest_.../signature-scheme" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{ "scheme": "standard_webhooks" }'
```

> **Switching is breaking and has no grace period**
>
> It mints a new secret and switches immediately. An overlap signs with two secrets under one scheme and cannot span two schemes. Make your endpoint accept the new scheme first, then switch, then fetch the new secret.

## Binary payloads and JavaScript receivers

SolidHooks signs the exact bytes it delivers. The reference `standardwebhooks` JavaScript library converts a Buffer payload to a string before computing its MAC, which is lossy for bytes that are not valid UTF-8, so it rejects those deliveries. This is deliberate: signing the lossy form would let one signature authenticate many different payloads.

If you deliver non-UTF-8 bodies, verify against the raw bytes. The Go, Python and PHP implementations already do. Only a JavaScript receiver is affected, and for that case choose `solidhooks_v1`, where you write the check yourself and nothing decodes the body before it is hashed. The Node.js sample above works on the raw Buffer under either scheme.

## Rotating the secret

`POST /v1/destinations/{id}/rotate-signing-secret` mints a new secret. On a `standard_webhooks` destination you can pass `overlapDays` of `1`, `7` or `30`, and deliveries carry both secrets' signatures until the window closes. Without `overlapDays`, and always on `solidhooks_v1`, rotation is a hard cutover: deliveries are signed with the new secret only the moment the call returns. See [Secret rotation](https://solidhooks.com/docs/secret-rotation).

## Next steps

* [Secret rotation](https://solidhooks.com/docs/secret-rotation) — roll secrets without dropping deliveries
* [Retries](https://solidhooks.com/docs/retries) — what happens when verification fails and you return an error
* [Destinations](https://solidhooks.com/docs/destinations) — create and configure the endpoint