# Signature verification

Source: https://solidhooks.com/docs/verification

> Prove inbound webhooks came from the provider you expect, choose a verifier for each source, and diagnose requests that get rejected.

A source's ingest URL is the only thing a provider needs to send you a webhook, which also makes it the only thing an attacker needs. Anyone who learns `https://sdhk.events/<maskId>` can post to it. A signature closes that gap: the provider computes a keyed hash of the request with a secret only the two of you hold, and SolidHooks recomputes it before storing anything.

Verification is configured per source. A request that fails it gets an opaque `401`, is stored as a `rejected` event so you can see why, and is never delivered.

## How it works

Every source has a `verifierType` and a `verifierConfig`. For a known provider you name the provider and send only the secret; SolidHooks fills in the rest of the scheme. For anything else you pick a verifier yourself.

The secret is encrypted at rest and never returned by the API. `GET` returns the rest of `verifierConfig` without it, and you can send that object straight back in a `PATCH`.

## Verifier types

| `verifierType`      | What it checks                                                                    | `verifierConfig` keys                                                                                                               |
| ------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `noop`              | Nothing. Every request is accepted.                                               | none (must be empty)                                                                                                                |
| `hmac`              | An HMAC of the body, in a header you name. Optional replay protection.            | `secret`, `hash`, `encoding`, `header`, `prefix`, `timestampHeader`, `timestampFormat`, `toleranceSeconds`, `signedPayloadTemplate` |
| `stripe`            | The `Stripe-Signature` header (`t=` timestamp plus one or more `v1=` signatures). | `secret`, `toleranceSeconds`                                                                                                        |
| `standard_webhooks` | The [Standard Webhooks](https://www.standardwebhooks.com) headers.                | `secret`, `toleranceSeconds`                                                                                                        |
| `basic_auth`        | An HTTP `Authorization: Basic` header.                                            | `username`, `password`                                                                                                              |
| `api_key`           | A static value in a header you name.                                              | `header_name`, `header_value`                                                                                                       |

Notes on the table:

* `hmac` takes `hash` of `sha256`, `sha512`, `sha1` or `md5`, and `encoding` of `hex`, `base64` or `base64url`. `hash`, `encoding`, `header` and `secret` are required on `provider: "generic"`.
* `prefix` is stripped from the header value before comparison (GitHub's `sha256=`, Slack's `v0=`).
* `stripe` and `standard_webhooks` reject any key that is not in their list. There is nothing about their scheme left to configure.
* `basic_auth` and `api_key` compare a static credential. They do not sign the body, so they give no protection against replay.
* Omit `verifierType` with an empty `verifierConfig` and the source is `noop`. Sending a secret that SolidHooks cannot match to a verifier is rejected rather than stored unverified.

## Provider presets

For a known provider, **the provider decides the whole signing scheme and you send only the secret.**

| `provider`  | Resolves to                                                                 | Timestamp and `toleranceSeconds`                     |
| ----------- | --------------------------------------------------------------------------- | ---------------------------------------------------- |
| `github`    | `hmac`, `x-hub-signature-256`, sha256, hex, prefix `sha256=`                | none                                                 |
| `linear`    | `hmac`, `linear-signature`, sha256, hex                                     | none                                                 |
| `shopify`   | `hmac`, `x-shopify-hmac-sha256`, sha256, base64                             | none                                                 |
| `paystack`  | `hmac`, `x-paystack-signature`, sha512, hex                                 | none                                                 |
| `nuvion`    | `hmac`, `x-nuvion-event-signature`, sha256, hex, signs `{timestamp}.{body}` | `x-nuvion-event-timestamp`, `iso8601`                |
| `stripe`    | `stripe`                                                                    | inside `Stripe-Signature`                            |
| `openai`    | `standard_webhooks`                                                         | `webhook-timestamp`                                  |
| `anthropic` | `standard_webhooks`                                                         | `webhook-timestamp`                                  |
| `resend`    | `standard_webhooks`                                                         | `webhook-timestamp` (`svix-timestamp` also accepted) |

`generic` has no preset: you choose the verifier and write the scheme yourself.

### Scheme keys are not overridable

A provider's signature scheme is a fact about that provider. Sending a `hash`, `encoding`, `header`, `prefix`, `timestampHeader`, `timestampFormat` or `signedPayloadTemplate` that differs from the preset is rejected, not ignored. Echoing the preset's own value back is accepted.

```json
{
  "code": "BAD_REQUEST",
  "message": "hash is not configurable for provider \"nuvion\" — it is fixed by that provider's signature scheme. Use provider \"generic\" to set it yourself."
}
```

This is deliberate. Overriding the scheme can only produce a source that rejects every webhook the provider really sends, or one that looks replay-protected and is not. Use `provider: "generic"` when you need control.

The preset only applies when the verifier matches the one the provider uses. `provider: "nuvion"` with `verifierType: "basic_auth"` injects nothing, and `verifierType: "noop"` always means no verification.

### Tolerance

`secret` is always yours. `toleranceSeconds` is also yours, but only where there is a timestamp for it to bound:

* **Allowed:** `stripe`, `openai`, `anthropic`, `resend`, `nuvion`, and `generic` sources that set a `timestampHeader`.
* **Rejected:** `github`, `linear`, `shopify` and `paystack`. They sign without a timestamp, so a tolerance would sit stored and inert.

It is a whole number of seconds from 1 to 86,400, defaulting to 300. The window is two-sided: a timestamp too far in the future is rejected like one that is too old.

## Create a source with verification

**GitHub**

Name the provider and send the secret you set in GitHub. `verifierType` and the signing scheme are filled in for you.

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "GitHub events",
    "provider": "github",
    "verifierConfig": { "secret": "<the-webhook-secret-you-set-in-github>" }
  }'
```

**Stripe**

Add `toleranceSeconds` only if you want something other than the 300-second default.

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Stripe events",
    "provider": "stripe",
    "verifierConfig": { "secret": "whsec_...", "toleranceSeconds": 300 }
  }'
```

**Standard Webhooks**

For a catalogued adopter (`openai`, `anthropic`, `resend`), name the provider. For any other producer that signs with the standard, use `generic` and set the verifier explicitly.

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Supabase webhooks",
    "provider": "generic",
    "verifierType": "standard_webhooks",
    "verifierConfig": { "secret": "whsec_your_signing_secret" }
  }'
```

**Custom HMAC**

On `generic`, you define the scheme. This one has no timestamp, so it signs the body alone.

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Internal service",
    "provider": "generic",
    "verifierType": "hmac",
    "verifierConfig": {
      "hash": "sha256",
      "encoding": "hex",
      "header": "x-signature",
      "secret": "<shared-secret>"
    }
  }'
```

See [Custom HMAC with replay protection](#custom-hmac-with-replay-protection) to bind a timestamp into the signature.

## Custom HMAC with replay protection

By default the `hmac` verifier signs the body alone, so a captured request stays valid forever. If your provider also sends a timestamp and folds it into the signature, add these optional keys.

| Key                     | Meaning                                                                                                                                                       |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `timestampHeader`       | Header carrying the timestamp, for example `x-slack-request-timestamp`. Setting it turns replay protection on.                                                |
| `timestampFormat`       | How the header is encoded: `unix_seconds` (default) or `iso8601`. Requires `timestampHeader`.                                                                 |
| `toleranceSeconds`      | How far the timestamp may drift from server time, either direction. Default `300`. Requires `timestampHeader`.                                                |
| `signedPayloadTemplate` | Layout of the bytes the provider signs, using `{timestamp}` and `{body}`. With `timestampHeader` set and no template, `{timestamp}.{body}` is stored for you. |

A Slack-style source, which signs `v0:<timestamp>:<body>`:

```bash
curl -s "$API/v1/sources" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "Slack events",
    "provider": "generic",
    "verifierType": "hmac",
    "verifierConfig": {
      "hash": "sha256",
      "encoding": "hex",
      "header": "x-slack-signature",
      "prefix": "v0=",
      "timestampHeader": "x-slack-request-timestamp",
      "signedPayloadTemplate": "v0:{timestamp}:{body}",
      "toleranceSeconds": 300,
      "secret": "<your-slack-signing-secret>"
    }
  }'
```

The timestamp encoding is declared, never guessed:

* `unix_seconds` accepts digits only. A millisecond value is read literally and lands far outside the window.
* `iso8601` requires an explicit offset, such as `2026-08-11T12:45:17.503Z`. A bare local-time value would shift the window by the server's UTC offset.
* An unparseable timestamp fails the request rather than skipping the replay check.
* The format only affects the window check. The signature always covers the header exactly as sent.

The API refuses configurations that only look protected: a template without `{body}`, more than one `{body}`, a `{timestamp}` with no `timestampHeader` to supply it, a `timestampHeader` the template never uses, or a `timestampFormat` or `toleranceSeconds` with no `timestampHeader`.

## Standard Webhooks verifier

`verifierType: "standard_webhooks"` implements the Standard Webhooks spec, the scheme OpenAI, Anthropic, Resend and a number of other producers sign with. Nothing about the scheme is yours to set, so `verifierConfig` accepts exactly two keys: `secret` (required) and `toleranceSeconds` (optional, default 300).

```text
webhook-id:        <producer-generated id>
webhook-timestamp: 1755300000
webhook-signature: v1,<base64> [v1,<base64> ...]
```

* **Header aliases.** Svix-hosted producers send the same three headers as `svix-id`, `svix-timestamp` and `svix-signature`. Both spellings are accepted. A request uses one spelling or the other, never a mix, and the set is chosen by whichever carries the id. Resend is the catalogued example that sends `svix-*`.
* **Signature list.** `webhook-signature` is a space-delimited list of `<version>,<signature>` entries, and any `v1` entry may match. This lets a producer mid-rotation send signatures for both its old and new secret. The first entry has no special authority.
* **`v1a` is skipped.** Asymmetric (ed25519) entries are ignored, not rejected, so a producer can send one alongside a `v1` we can check. Verifying them is not supported.
* **The key.** The MAC is `base64(HMAC-SHA256(key, "<webhook-id>.<webhook-timestamp>.<raw-body>"))`. The key is the secret with its `whsec_` prefix removed and the remainder **base64-decoded**: the raw bytes, never the literal string.

Sending any HMAC scheme key (`hash`, `encoding`, `header`, `prefix`, `timestampHeader`, `timestampFormat`, `signedPayloadTemplate`) to this verifier is rejected:

```json
{
  "code": "BAD_REQUEST",
  "message": "hash is not part of the standard_webhooks verifier's config — the Standard Webhooks spec fixes its own header names and signature scheme. Use verifierType \"hmac\" with provider \"generic\" to configure a scheme yourself."
}
```

The presets `openai`, `anthropic` and `resend` also default `idempotencyKeys` from the id header. See [Idempotency and deduplication](https://solidhooks.com/docs/idempotency).

## Paystack uses your account secret

Paystack signs with your account secret key (`sk_live_...`), not a scoped per-webhook signing secret. The value you store is therefore a full API credential. It is encrypted at rest and never returned by the API, but treat its exposure as you would the key itself.

## When verification fails

The caller gets a `401` with the same message every time:

```json
{ "ok": false, "code": "UNAUTHORIZED", "message": "Signature verification failed" }
```

The message never says which check failed. Telling an unauthenticated caller why would turn the `401` into an oracle for guessing signatures.

The reason is recorded for you instead. SolidHooks stores the request as an event with `status: "rejected"` and a `rejectionReason`. Find them with `GET /v1/events?status=rejected`. Rejected events are never deduplicated, never counted against your delivery quota, never delivered, and cannot be replayed.

| `rejectionReason`            | Meaning                                                                    |
| ---------------------------- | -------------------------------------------------------------------------- |
| `signature_mismatch`         | Everything parsed, but the computed signature or credential did not match. |
| `missing_signature_header`   | The header carrying the signature or credential was absent.                |
| `missing_id_header`          | Standard Webhooks only: `webhook-id` (or `svix-id`) was absent.            |
| `missing_timestamp_header`   | Replay protection is on and the timestamp header was absent.               |
| `malformed_signature_header` | The signature header was present but not in the scheme's expected shape.   |
| `malformed_timestamp`        | The timestamp could not be parsed in its declared format.                  |
| `timestamp_out_of_tolerance` | The timestamp was valid but outside the replay window.                     |
| `verifier_misconfigured`     | The verifier is missing a field it needs.                                  |
| `unsupported_verifier`       | The source's verifier type has no implementation.                          |

> **Rejected events are a sample**
>
> Recording rejected requests is rate-limited per source, so during a flood the rejected-event log is a representative sample, not a complete ledger of every `401`.

Two failures happen before verification and are not stored as rejected events: a body larger than the source's `maxPayloadSizeKb` and a body containing a NUL byte.

## Test a signature

`POST /v1/sources/{id}/test-signature` answers "why is this request being rejected?" without waiting for real traffic. Send the exact headers and body a producer sent. It verifies them against the source's live secrets with the same code ingest uses.

```bash
curl -s "$API/v1/sources/src_.../test-signature" \
  -H "authorization: Bearer $SDHK_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "headers": {
      "x-hub-signature-256": "sha256=..."
    },
    "body": "{\"action\":\"opened\"}"
  }'
```

```json
{ "verified": false, "slot": null, "reason": "signature_mismatch", "rotating": false }
```

* **`body`** must be the raw body, byte for byte. Re-serialising JSON changes the bytes and the signature will not match.
* **`headers`** are matched case-insensitively.
* **`reason`** uses the same taxonomy as `rejectionReason` above. Unlike the inbound `401`, this endpoint returns it, because the caller is the authenticated owner reading their own diagnostics. It is `null` when verification succeeds.
* **`slot`** is `current`, `previous` or `null`. During a [secret rotation](https://solidhooks.com/docs/secret-rotation) both secrets are tried, and `previous` tells you the producer has not switched to the new one yet. `rotating` says whether a rotation window was open.
* **Permission:** it needs only `source: read`. Diagnosing a failing producer does not require the ability to rotate a secret.
* **Side effects:** none. Running it never records slot usage, so testing cannot disturb the counters a rotation reads.
* **Rate limit:** per source, about 1 request per second with a burst of 10. Over it you get `429 RATE_LIMITED` with `data: { limit, remaining, retryAfterSeconds }`. A burst of clicks is fine; a loop is not.

## Next steps

* [Secret rotation](https://solidhooks.com/docs/secret-rotation) — swap a verifier secret with both accepted during an overlap window
* [Idempotency and deduplication](https://solidhooks.com/docs/idempotency) — stop duplicate deliveries from the same producer
* [Sources](https://solidhooks.com/docs/sources) — create and configure the endpoints that verify
* [Events](https://solidhooks.com/docs/events) — find rejected events and read their reasons