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
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 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:
hmactakeshashofsha256,sha512,sha1ormd5, andencodingofhex,base64orbase64url.hash,encoding,headerandsecretare required onprovider: "generic".prefixis stripped from the header value before comparison (GitHub'ssha256=, Slack'sv0=).stripeandstandard_webhooksreject any key that is not in their list. There is nothing about their scheme left to configure.basic_authandapi_keycompare a static credential. They do not sign the body, so they give no protection against replay.- Omit
verifierTypewith an emptyverifierConfigand the source isnoop. 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.
{
"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, andgenericsources that set atimestampHeader. - Rejected:
github,linear,shopifyandpaystack. 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.
| 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>:
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_secondsaccepts digits only. A millisecond value is read literally and lands far outside the window.iso8601requires an explicit offset, such as2026-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-timestampandsvix-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 sendssvix-*. - Signature list.
webhook-signatureis a space-delimited list of<version>,<signature>entries, and anyv1entry may match. This lets a producer mid-rotation send signatures for both its old and new secret. The first entry has no special authority. v1ais skipped. Asymmetric (ed25519) entries are ignored, not rejected, so a producer can send one alongside av1we 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 itswhsec_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.
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.
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 }bodymust be the raw body, byte for byte. Re-serialising JSON changes the bytes and the signature will not match.headersare matched case-insensitively.reasonuses the same taxonomy asrejectionReasonabove. Unlike the inbound401, this endpoint returns it, because the caller is the authenticated owner reading their own diagnostics. It isnullwhen verification succeeds.slotiscurrent,previousornull. During a secret rotation both secrets are tried, andprevioustells you the producer has not switched to the new one yet.rotatingsays 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_LIMITEDwithdata: { 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