SolidHooksSolidHooks/ Docs

Signature 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

verifierTypeWhat it checksverifierConfig keys
noopNothing. Every request is accepted.none (must be empty)
hmacAn HMAC of the body, in a header you name. Optional replay protection.secret, hash, encoding, header, prefix, timestampHeader, timestampFormat, toleranceSeconds, signedPayloadTemplate
stripeThe Stripe-Signature header (t= timestamp plus one or more v1= signatures).secret, toleranceSeconds
standard_webhooksThe Standard Webhooks headers.secret, toleranceSeconds
basic_authAn HTTP Authorization: Basic header.username, password
api_keyA 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.

providerResolves toTimestamp and toleranceSeconds
githubhmac, x-hub-signature-256, sha256, hex, prefix sha256=none
linearhmac, linear-signature, sha256, hexnone
shopifyhmac, x-shopify-hmac-sha256, sha256, base64none
paystackhmac, x-paystack-signature, sha512, hexnone
nuvionhmac, x-nuvion-event-signature, sha256, hex, signs {timestamp}.{body}x-nuvion-event-timestamp, iso8601
stripestripeinside Stripe-Signature
openaistandard_webhookswebhook-timestamp
anthropicstandard_webhookswebhook-timestamp
resendstandard_webhookswebhook-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.

{
  "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

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

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>" }
  }'

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.

KeyMeaning
timestampHeaderHeader carrying the timestamp, for example x-slack-request-timestamp. Setting it turns replay protection on.
timestampFormatHow the header is encoded: unix_seconds (default) or iso8601. Requires timestampHeader.
toleranceSecondsHow far the timestamp may drift from server time, either direction. Default 300. Requires timestampHeader.
signedPayloadTemplateLayout 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>:

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).

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:

{
  "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.

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:

{ "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.

rejectionReasonMeaning
signature_mismatchEverything parsed, but the computed signature or credential did not match.
missing_signature_headerThe header carrying the signature or credential was absent.
missing_id_headerStandard Webhooks only: webhook-id (or svix-id) was absent.
missing_timestamp_headerReplay protection is on and the timestamp header was absent.
malformed_signature_headerThe signature header was present but not in the scheme's expected shape.
malformed_timestampThe timestamp could not be parsed in its declared format.
timestamp_out_of_toleranceThe timestamp was valid but outside the replay window.
verifier_misconfiguredThe verifier is missing a field it needs.
unsupported_verifierThe 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.

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\"}"
  }'
{ "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 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 — swap a verifier secret with both accepted during an overlap window
  • Idempotency and deduplication — stop duplicate deliveries from the same producer
  • Sources — create and configure the endpoints that verify
  • Events — find rejected events and read their reasons