Skip to content

Inbound Webhooks ​

Inbound webhooks let external services (Stripe, GitHub, Slack, etc.) reach a server function automatically. Each webhook has a public URL, signature verification, and delivers the event payload as the function's input. This is the inbound half of a third-party integration; the outbound half — your app calling the provider's API — is a configured integration. Most integrations use both.

Defining a Webhook ​

A webhook is declared inside the function it triggers — see The webhook trigger:

toml
# primitive/dev/functions/stripe-events.toml
[function]
key = "stripe-events"
entry = "functions/stripe-events/index.ts"
access = "false"                    # no member calls it over HTTP; the webhook fire is not gated

[function.triggers.webhook]
verificationScheme = "stripe"
signingSecret = "{{secrets.STRIPE_WEBHOOK_SECRET}}"

The receive endpoint is POST /app/{appId}/webhook/{functionKey} — the webhook answers on the function's own key, one per function. When an event arrives, the platform verifies the signature and runs the function with the event payload as input. Supported verification schemes are stripe, github, slack, discord, jwt, plaid and custom; an unsigned none scheme is refused on a function trigger, because anyone could run the function. A provider none of the named schemes matches is described declaratively on custom (see Describing a provider's own signature scheme).

Everything below — signing secrets, verification, deduplication, previewing and verifying deliveries — is the same mechanism whichever function declares the trigger.

Signing Secrets ​

signingSecret holds a whole {{secrets.KEY}} reference and nothing else — the provider's secret itself lives only in the app secret store. Store the value first, then reference it:

bash
primitive secrets set STRIPE_WEBHOOK_SECRET --value whsec_...

In the admin console the same field is a picker over the app's secrets, with a row that creates one without leaving the webhook form; it appears only for the schemes that take a signing secret.

A raw value is rejected with SIGNING_SECRET_MUST_BE_SECRET_REF, and a reference to a key that does not exist is rejected naming the key. The schemes that verify with public key material — discord, jwt, plaid — reject a supplied signingSecret with SIGNING_SECRET_NOT_SUPPORTED_FOR_SCHEME; switching a webhook onto one of them clears the stored reference, so switching back means supplying it again. Each referenced credential uses one of the app's 100 secret slots, so an app with many signed webhooks needs one key per webhook.

Reads report a signingSecretStatus: reference is the healthy state, and unset means no value is stored.

malformed-reference means the stored value carries reference syntax that no {{secrets.KEY}} reference accounts for ({{secrets.foo}}, {secrets.KEY}, {{vars.KEY}}), or an otherwise-valid reference carrying an invisible character — a zero-width space or a byte-order mark, picked up by pasting the reference out of a document. Such a value is rejected on write, and an existing one is reported here rather than resolved with the stray character inside the signing key. Reference text is public — it round-trips into version-controlled TOML — so it is never used as a signing key, and every signed delivery to that webhook is already rejected 401 (rejectionReason: "secret_unresolved"). Store the real signing secret and point the field at it. A signing-scheme webhook with no stored value at all is rejected the same way (rejectionReason: "signing_secret_unset") — an empty HMAC key would be forgeable.

Rotating a signing secret. Changing the value under an existing key (primitive secrets set KEY --value <new>) is a sharp cutover — a key holds exactly one value. To accept both the old and new secret during a provider's overlap window, rotate onto a different key:

bash
primitive secrets set STRIPE_WEBHOOK_SECRET_2 --value whsec_new...
primitive webhooks rotate-secret <webhookId> --secret "{{secrets.STRIPE_WEBHOOK_SECRET_2}}"

That moves the previous reference into previousSigningSecret, which keeps verifying for secretGracePeriodMs (24 hours by default, 30 days maximum — a longer value is rejected with SECRET_GRACE_PERIOD_OUT_OF_RANGE, and a webhook already storing one has it capped at the maximum when deliveries are checked). Set it to 0 to cut over the moment the rotation lands. Reads report previousSigningSecretStatus alongside the current slot's, so you can see what the grace slot actually holds — combine it with secretRotatedAt and secretGracePeriodMs to work out when it stops being accepted. Rotating onto the key already in use is rejected — it would provide no overlap. The rotation survives later pushes: a push only touches the signing secret when the value written in the TOML itself changed.

Securing a webhook-triggered function ​

A webhook fire has no caller — there is no member to run it as, so ctx.user is null inside the handler. A trigger fire skips the function's access gate: what admits a delivery is its signature verification. The gate governs the other door into the same function, an HTTP call, and that door is open to whoever the expression admits. So a function that only a webhook should run declares access = "false", a gate no member passes: it closes the function's HTTP route to members while deliveries keep running it. App owners and admins bypass every gate, so they can still invoke the function directly to test it.

Inspecting Webhooks from the CLI ​

Inspect a webhook and its recent deliveries (accepted, rejected, duplicate) from the CLI, using the webhook id primitive functions get <function-id> prints under its trigger:

bash
primitive webhooks events <webhook-id>
primitive webhooks rotate-secret <webhook-id>
primitive webhooks test <webhook-id> --payload '{"type":"charge.succeeded","data":{"id":"ch_1"}}'
primitive webhooks verify <webhook-id> --header 'Stripe-Signature: t=…,v1=…' --body-file captured.json

A function's webhook lives under the function, so primitive functions get <function-id> is also where you read its status and receiver URL.

Limits and Removal ​

One limit: 50 webhooks per app. Every webhook carries its own budgets — a body cap per delivery, a delivery record per event, key fetches for the remote-JWKS schemes — so the count is what bounds them for the app as a whole. A create past the limit is refused with a 400 carrying WEBHOOK_LIMIT_REACHED, on the API and on primitive config push alike; config push checks up front how many webhooks the tree would create, so a push that would go past the limit stops before anything is written.

A function's webhook is its declaration: removing [function.triggers.webhook] and pushing takes the webhook out of service — the receiver answers 410 — and keeps its URL and its delivery log; putting the block back and pushing resumes deliveries on the same URL (see Editing and removing triggers).

Public-Key Schemes: Discord and JWT ​

Schemes that verify with a public key rather than a shared secret take their settings from a [verification.<scheme>] table instead of signingSecret. Discord signs with your application's public key:

toml
# primitive/dev/functions/discord-interactions.toml
[function]
key = "discord-interactions"
entry = "functions/discord-interactions/index.ts"
access = "false"

[function.triggers.webhook]
verificationScheme = "discord"

[function.triggers.webhook.verification.discord]
publicKey = "<64-character hex public key>"

The jwt scheme covers providers that sign each delivery as a JSON Web Token carrying a hash of the request body — the token arrives in a header you name, and the keys that verify it come from a JWKS you either paste in or point at:

toml
# primitive/dev/functions/provider-events.toml
[function]
key = "provider-events"
entry = "functions/provider-events/index.ts"
access = "false"

[function.triggers.webhook]
verificationScheme = "jwt"
toleranceSeconds = 300

[function.triggers.webhook.verification.jwt]
header = "X-Provider-Signature"
algorithms = ["ES256"]           # ES256/384/512, RS256, PS256, EdDSA
bodyHashClaim = "request_body_sha256"
bodyHashEncoding = "hex"         # hex | base64 | base64url
issuer = "https://provider.example.com"   # optional
jwks = { keys = [ { kid = "key-1", kty = "EC", crv = "P-256", x = "...", y = "..." } ] }

Each key in the JWKS needs a kid, and a kid is 1–128 characters from A-Z, a-z, 0-9, _, . and - — the same shape the token's kid header has to have for a key to be selected. Anything outside that set is rejected when you push the webhook, rather than stored as a key no delivery could match. A document fetched from a URL is treated more tolerantly, because you do not control what the provider publishes: a key whose kid is outside that set is ignored and the rest of the document still verifies deliveries, so one odd key at the provider cannot break the webhook. If nothing selectable is left, the document is rejected.

Providers that publish their keys at a URL — Auth0, Okta, and OIDC-style signers generally — are configured with jwksUrl instead of jwks. Supply exactly one of the two: with a URL, the platform fetches and caches the document, and a key rotation needs no config change.

toml
[function.triggers.webhook.verification.jwt]
header = "X-Provider-Signature"
algorithms = ["ES256"]
bodyHashClaim = "request_body_sha256"
jwksUrl = "https://tenant.auth0.com/.well-known/jwks.json"

The URL must be https on port 443 and name a public hostname — an IP address, a single-label or .internal-style name, credentials in the URL, or a fragment are all rejected when you save the webhook. If the endpoint is unreachable when a delivery arrives, the platform serves the last document it fetched successfully; if it has none, the delivery is rejected with a 401 rather than accepted.

A delivery is accepted only when the token verifies against a key in that JWKS, its algorithm is one you listed, it was issued inside toleranceSeconds, and bodyHashClaim matches a SHA-256 of the delivered body — the body hash is what ties the signature to that exact payload. Anything else is a 401. Because a signature proves who sent an event but not that it is new, signed schemes keep deduplication on, with a window long enough to outlive the period in which a captured delivery still verifies (toleranceSeconds plus the clock skew each scheme allows). The platform sets that window for you when you don't choose one, and rejects a setting that is too short.

The Plaid Preset ​

Plaid signs the same way but publishes no JWKS: its keys are fetched from Plaid's API one key at a time. The plaid scheme is that preset — the header, the algorithm and the body-hash claim are fixed, so all you supply is the environment and your Plaid API credentials:

toml
# primitive/dev/functions/plaid-events.toml
[function]
key = "plaid-events"
entry = "functions/plaid-events/index.ts"
access = "false"

[function.triggers.webhook]
verificationScheme = "plaid"

[function.triggers.webhook.verification.plaid]
environment = "sandbox"          # sandbox | production
clientId = "{{secrets.PLAID_CLIENT_ID}}"
secret = "{{secrets.PLAID_SECRET}}"

The acceptance rules above apply unchanged, with the fixed values standing in for the ones you would otherwise list: the token must verify against the key Plaid serves for its kid, its algorithm must be ES256, and the request_body_sha256 claim must match a SHA-256 of the delivered body. Those three keys are the whole table: issuer, audience and eventIdClaim are jwt settings that plaid does not carry, so writing one is rejected (PLAID_UNKNOWN_CONFIG_KEY) rather than stored and ignored. Both credentials must be {{secrets.KEY}} references, and the secrets they name must already exist when you first push the webhook, when you change its verification config, or when you switch the webhook back onto plaid with a config you did not resend — that push is rejected 400 rather than failing later as a 401. Re-pushing an unchanged config on an unchanged scheme is accepted even after a referenced secret has been deleted, so one dead reference does not start failing every later config push; that webhook fails closed at delivery instead. Key rotation needs no change here: a delivery signed with a new key is fetched once and cached, and a key Plaid reports as expired is never accepted. If a key can't be fetched — the credentials no longer resolve, or Plaid is unreachable — the delivery is rejected with a 401, never accepted on trust.

Body Size and Credential Rules ​

Whatever the scheme, a request body over the webhook's size cap is rejected with a 401 before it is read, recorded on the delivery record as body_too_large. The cap defaults to 5 MiB and is configurable per webhook via maxBodyBytes (up to a platform maximum of 25 MiB); a value above the maximum, or a non-positive one, is rejected at write time.

A credential inside a [verification.*] table must be a {{secrets.KEY}} reference, and the reference must be the whole value — a mixed value like "sk_live_abcd{{secrets.SUFFIX}}" is rejected too, because the literal half of it would be stored and returned in cleartext. The rest of the table is stored and returned as written, and a reference comes back as you wrote it, so a compliant file survives a pull and push unchanged. Once a file declares a [verification] section, that section owns the stored settings: removing the scheme's table clears them on the next push, which is how you revoke a key that has been compromised.

Reference the signing secret from your app secret store as {{secrets.KEY}} so it stays out of version control; the platform resolves it server-side when it verifies an incoming event. The referenced secret must already exist when you set or change it — that push is rejected 400. Re-pushing the same reference is accepted even after the secret has been deleted, so an unrelated edit is not blocked by it. If it can't be resolved at delivery time, the webhook rejects the request with a 401 rather than verifying against the literal reference.

Deduplication and Replay Protection ​

The key a signed delivery is deduplicated on comes only from material the signature covers: the identifier the provider signs where there is one (stripe's body id, slack's event_id, discord's interaction id, the jwt/plaid eventIdClaim, a declarative custom configuration's dedup.signedBodyPointer), otherwise a SHA-256 of the exact signed payload — the body for github, <t>.<body> for custom, the body for jwt/plaid. Every key is prefixed with a tag saying which of those it is: sha256: for a digest the platform computed over the signed payload, id: for an identifier the delivery itself carried, and cap: for the rehash of a key too long to index. The tags exist so the two kinds of value can never name each other — without them, a sender able to sign for the webhook could pick an event id that reads as the hash a later, id-less delivery will produce, and suppress that legitimate event before it arrives. Replaying a captured delivery with X-GitHub-Delivery or X-Webhook-Event-Id edited or removed therefore does not get past deduplication: it is answered 200 {"received": true, "duplicate": true} and the function does not run again. Those headers are still recorded as the provider's externalEventId for display and correlation. The delivery log shows the derived key as dedupKey on accepted deliveries only: the duplicate status is itself the "this one was suppressed" signal, and the dedupKey on the accepted row tells you which earlier delivery it collapsed into. The reverse doesn't work — a duplicate row carries no key, and the delivery id beside it comes from a header a replay can change — so match a suppressed delivery to its original on the payload summary and timestamp. On custom the key covers the signed timestamp, so a sender that retries the same logical event by re-signing it with a fresh t runs the function a second time — a shared X-Webhook-Event-Id does not suppress it. Make the function idempotent, or put the event id in the signed body.

github is the one built-in scheme whose dedup entries never expire (a declarative custom configuration with no freshness is the other case, for the same reason): GitHub signs no timestamp, so a captured delivery never goes stale and no finite window bounds it. Plan for the consequence — GitHub's manual Redeliver re-sends a byte-identical body, so from the second delivery onward it is answered duplicate and the function does not run. Invoke the function directly instead: primitive functions invoke <key> --input '<json>'. Rotating the signing secret does not clear it — the key is a hash of the body, not of the secret, so the re-signed redelivery is still a duplicate.

Because that accepted delivery record is what suppresses a replay, the platform will not dispatch without it: if the record cannot be written, the delivery is answered 503 (Failed to record webhook delivery) and the function does not run. Retrying is the resolution — the retry re-runs the whole path — so allow for it in your provider's retry policy. It only applies where a dedup key was due.

Previewing Deliveries with webhooks test ​

webhooks test has two modes. By default it only signs: it returns the exact request body and the signature headers a real sender would use, and does not post them to your receive endpoint, so it runs no function and records no delivery. Adding --deliver posts them for real. --payload is signed exactly as given — pass the event object directly, not wrapped in {"payload": ...}. Omit it to sign a canned webhook.test ping instead.

webhooks test answers 200 only when the headers it returns are ones your receiver would accept. Every other case is a 400 carrying a code that names the condition and its remediation, rather than a preview that looks successful and produces a delivery the receiver answers 401. On any of them the CLI prints the server's message and its code and exits non-zero:

codeWhat it means
WEBHOOK_TEST_SIGNING_UNSUPPORTEDThe scheme is verified with public key material only — discord, jwt and plaid. The platform holds no private key for the webhook, so no preview signature can exist. Use primitive webhooks verify on a delivery you captured instead.
SIGNING_SECRET_MUST_BE_SECRET_REFThe signingSecret is unset, blank, or carries reference syntax that does not resolve. Deliveries to that webhook are already being rejected.
MISSING_CONFIG_SECRET_REFThe referenced app secret no longer exists.
WEBHOOK_TEST_UNSUPPORTED_CONFIG / SIGNATURE_CONFIG_INVALIDThe custom scheme's declarative configuration cannot be previewed, or is not valid (see Describing a provider's own signature scheme).

Delivering the payload for real with --deliver ​

Adding --deliver posts that signed body to the webhook's own receive endpoint, POST /app/{appId}/webhook/{functionKey} — the same unauthenticated path a provider posts to:

bash
primitive webhooks test <webhook-id> \
  --payload '{"type":"customer.subscription.updated","data":{"id":"sub_1"}}' \
  --deliver

Nothing is bypassed and nothing is simulated. The signature the preview produced is verified for real, and so are the body cap, the availability check, the IP allowlist, the handshake rules and deduplication; a verified delivery runs the function and writes a run row. The schemes the platform cannot sign — discord, jwt, plaid — are refused at the preview step, as above, so --deliver cannot produce a provider-signed event nobody holds the key for.

This is a real delivery, with real side effects. The command says so before it posts anything: a real event row is written to the delivery log, and the signed body's dedup key is spent, so delivering those exact bytes again is suppressed as a replay. On the github scheme that is permanent — its dedup entries never expire, so an identical body can never be delivered to that webhook again. Vary the payload between test deliveries (any byte change is a different key); a canned webhook.test ping carries a fresh timestamp, so it is always safe to repeat, as is any custom, stripe or slack payload, whose signature covers a timestamp.

What it reports is what happened. Only a dispatch it can prove exits 0: the CLI takes a snapshot of the delivery log before posting, then finds this delivery's own event row by the dedup key the preview returned — an exact per-request correlator, never a body hash and never the client's clock — and confirms the run that row names really exists on primitive functions runs <function-id>. Everything else is named for what it was and exits non-zero:

What the CLI reportsWhat happened
dispatchedThe delivery was accepted, and the run it names was confirmed.
refused — not in service (410)The function no longer declares the webhook trigger. A preview still works against it (checking a signature is what you do before putting one back in service); the delivery is refused. Putting the [function.triggers.webhook] block back and pushing restores it.
refused — IP not allowed (403)Your machine's address is not on the webhook's IP allowlist.
refused — signature rejected (401)The receiver did not accept the preview's signature.
refused — suppressed replayThese exact signed bytes were delivered before. Vary the payload.
unconfirmedThe delivery was sent, but the CLI could not prove what it did. Read primitive webhooks events <webhook-id> for the truth.

A 200 alone is never read as success, because a handshake rule may answer exactly 200 {"received": true} before any dispatch. The cases that land under unconfirmed are worth knowing:

  • No row carrying the key became visible. The receiver records that key only on a delivery it accepted and dispatched — a handshake short-circuit, a suppressed replay, a rejection and a 202 acknowledged-without-dispatch are all written without one, which is what keeps them from suppressing a later delivery. So this usually means the delivery was not dispatched, and the delivery log says which of those it was.
  • The run could not be confirmed. The row named a run the runs surface does not show.
  • The delivery log could not be read, or no response came back from the receive endpoint. The delivery still happened.

--json carries the same verdict as an outcome field of "dispatched", "refused" or "unconfirmed", alongside the preview, the receive endpoint's HTTP outcome, the resolved event row and the run confirmation — so a script can tell a refusal from an unconfirmed dispatch without parsing prose.

Checking a captured delivery ​

When a real delivery was rejected 401 and you want to know why — or when the scheme is one webhooks test cannot preview — hand the delivery you captured to webhooks verify:

bash
primitive webhooks verify <webhook-id> \
  --header 'Stripe-Signature: t=1754...,v1=abc...' \
  --body-file captured-delivery.json

It runs the webhook's configured verifier over exactly those bytes and headers and prints the verdict, the scheme, and the rejection reason — the same vocabulary primitive webhooks events shows for a real delivery (sig_invalid, sig_expired, signing_secret_unset, …). It exits 0 when the signature verifies and non-zero otherwise, so it composes in a script.

--body-file is read as raw bytes, not text: a capture that is not valid UTF-8 is sent base64-encoded, so the verifier sees exactly the bytes on disk rather than a lossy decoding of them. Use it rather than --body whenever the capture came off the wire.

Nothing is delivered: no function runs, and the check is deliberately not recorded in primitive webhooks events — that log is the record of what the provider really sent, and putting synthetic checks in it would spoil the thing you use to answer that question.

It answers signature verification only. A real delivery is also subject to the webhook's status and IP allowlist (checked before verification) and to handshake rules and deduplication (after it), so a verified: true verdict does not promise the delivery would have run the function. When the webhook is out of service the command says so, because a live delivery would have been refused before its signature was ever checked.

Over the API this is POST /admin/api/apps/{appId}/webhooks/{webhookId}/verify, taking { headers, rawBody } or { headers, bodyBase64 } (exactly one body form — base64 for bytes that are not valid UTF-8). headers takes a flat name-to-value object, or ordered [name, value] pairs when the capture carries one field name on more than one line. A bad signature is a 200 verdict, not an error; a 400 means the request itself was malformed (WEBHOOK_VERIFY_BODY_INVALID, WEBHOOK_VERIFY_HEADERS_INVALID, WEBHOOK_VERIFY_SCHEME_UNSUPPORTED).

To actually run the function, add --deliver (above), or send the returned body with the returned signature headers to POST /app/{appId}/webhook/{functionKey} yourself. Either way, on a github webhook that delivery is what makes a body single-use: the key is a hash of the body and github entries never expire, so replaying the same signed body is answered duplicate the second time and does not run the function again. Vary the payload between real test deliveries — any change to the JSON is a different body, so a different key. The canned ping carries a fresh timestamp and is safe to repeat.

Describing a provider's own signature scheme ​

When a provider signs its webhooks in a way none of the named schemes match, describe that scheme with a [function.triggers.webhook.verification.custom.detachedSignature] table on the custom scheme. The platform verifies deliveries exactly as declared, so a new provider needs no platform release.

toml
# primitive/dev/functions/acme-events.toml
[function]
key = "acme-events"
entry = "functions/acme-events/index.ts"
access = "false"

[function.triggers.webhook]
verificationScheme = "custom"
signingSecret = "{{secrets.ACME_WEBHOOK_SECRET}}"
toleranceSeconds = 300

[function.triggers.webhook.verification.custom.detachedSignature]
primitive = "hmac-sha256"          # HMAC-SHA256, over the parts declared below
encoding = "base64url"             # hex | base64 | base64url

# Where the signature is. Omit `key` when the whole header IS the signature;
# set it to read one field of a comma-separated `key=value` header.
# `prefix` is stripped from each candidate (GitHub's `sha256=`, say).
signature = { header = "X-Acme-Signature" }

# The freshness timestamp: which source it comes from, and how it is spelled.
# It must also be one of the signedPayload parts below — otherwise the value
# checked for staleness sits outside the signature and a captured delivery
# could be replayed with a fresh one.
freshness = { from = { header = "X-Acme-Timestamp" }, format = "unixSeconds" }

# Optional: the provider's own event id, from inside the SIGNED body.
dedup = { signedBodyPointer = "/event/id" }
# Optional: an id header the signature does not cover. Display and
# correlation only — it can never drive deduplication.
externalEventId = { untrustedHeader = "X-Acme-Delivery" }

# The ordered pieces the signature covers, exactly as the provider builds them.
# Each part carries exactly one of `literal`, `body`, `header` or
# `signatureField`, and exactly one part must be `body = true`. A `literal`
# separator goes between any two parts whose length the sender chooses — here,
# the timestamp header and the body.
[[function.triggers.webhook.verification.custom.detachedSignature.signedPayload]]
header = "X-Acme-Timestamp"

[[function.triggers.webhook.verification.custom.detachedSignature.signedPayload]]
literal = "|"

[[function.triggers.webhook.verification.custom.detachedSignature.signedPayload]]
body = true

That declares: HMAC-SHA256 over <X-Acme-Timestamp>|<raw body>, signature base64url in X-Acme-Signature, freshness from the same timestamp header, deduplication on the signed /event/id.

The freshness format is one of unixSeconds, unixMilliseconds, rfc3339 (an offset is required — a local time with no zone names no instant) or httpDate (IMF-fixdate, Sun, 06 Nov 1994 08:49:37 GMT; the obsolete RFC 850 and asctime forms are rejected). Because an HTTP-date contains a comma, and a comma separates fields in the key=value grammar, httpDate can only be read from a header of its own, not from a signatureField.

dedup.signedBodyPointer is an RFC 6901 JSON Pointer into the request body, resolved only after the signature verifies. It is bounded — at most 8 tokens, 64 characters per token, 256 overall — and accepts a non-empty string or a whole number. Anything else (a missing path, an object, an empty string) falls back to a SHA-256 of the signed payload, so a delivery is never left without a key.

Everything is checked when you push the webhook, not on the first live delivery. A configuration accepted at write time cannot fail structurally at delivery. The coded 400s are SIGNATURE_CONFIG_INVALID (a bad type, a missing or unrecognized key — unknown keys are rejected at every level — or a part that carries two of literal / body / header / signatureField), SIGNATURE_BODY_PART_REQUIRED (the parts do not cover the body exactly once: no body = true part, two of them, a body that is not exactly true, or a part that names body alongside another key), FRESHNESS_NOT_SIGNED (the freshness source is not one of the signed parts), SIGNATURE_POINTER_INVALID (the pointer is outside its bounds) and SIGNATURE_PRIMITIVE_UNSUPPORTED (hmac-sha256 is the primitive a declarative configuration uses — reach for verificationScheme = "discord" when the provider signs with Ed25519, or "jwt" for a JWS signature).

Two of those rules are about not signing over the signature: a signed-payload header part cannot be the signature header itself, and a signatureField part cannot be the signature's own key. Reading a different field of the signature header is fine, and is how a Stripe-style t=…,v1=… header is expressed: signature = { header = "X-Acme-Signature", key = "v1" } with a { signatureField = "t" } part. Relatedly, when signature.key is set the signature sits inside a comma-separated field, so signature.prefix cannot contain a comma, and neither a signature.key nor a signatureField name may contain , or =.

One more rule is about where one part ends and the next begins: a literal has to separate any two parts whose length the sender chooses — a header or signatureField part next to body = true, or two of them in a row. Without that separator the assembled bytes carry no boundary at all, so a captured delivery could be replayed with bytes shifted across it under the same signature. Every literal must be a non-empty string, and a separating one must not overlap itself: no prefix of it may also be a suffix, so |, . and -> are fine while ::, .. and abab are rejected. A self-overlapping separator can match across the boundary it marks, which leaves the same two-way split. Any single character satisfies this, and a single character is what providers use. If a provider genuinely signs two variable-length pieces with nothing between them, that scheme cannot be described here.

The separator also has to be a string the values beside it do not contain, and that part is checked per delivery rather than when you push: a delivery whose header or signatureField value carries a literal next to it is rejected as a malformed signature. With all of that in place each boundary is the first — or, on the far side of the body, the last — occurrence of its separator, so the signed bytes read one way. Choose the separator with the provider's own values in mind: | is safe against a JSON body, while . next to an rfc3339 timestamp only works if that provider sends whole seconds, since a fractional second carries a . of its own. Some separators can never work: -, :, T and Z appear in every rfc3339 timestamp, and ,, a space and : in every httpDate one, so a configuration pairing one of those with that format verifies no delivery at all. primitive webhooks test refuses to preview a clash with the timestamp, naming it, instead of returning headers the receiver would reject — and when a rendering of the format avoids the separator, that is the rendering the preview signs.

There is a size budget on the parts — at most 8 of them, with at most 1 KiB of literal text across them. It covers only text you write: a header or signatureField part carries whatever the sender sent, so its length is never held against your configuration.

freshness is optional. A provider that signs no timestamp can omit it — and then, like github, no finite window bounds a replay, so that webhook's deduplication entries never expire. The tradeoff is the same one: a byte-identical redelivery is answered duplicate from then on.

Without a detachedSignature table, custom verifies its own fixed format: t=<unix>,v1=<hmac hex> in X-Webhook-Signature over <t>.<body>, with X-Webhook-Event-Id as the external id. Other free-form keys under [verification.custom] are stored as written.

primitive webhooks test <webhookId> signs its preview from your declared configuration, so the headers it hands back are ones a real delivery to that webhook accepts. When the stored configuration is not one the platform can sign a preview for, it answers 400 WEBHOOK_TEST_UNSUPPORTED_CONFIG naming the blocking part rather than returning a signature no receiver would take. The case you can hit from a valid configuration is signing over a header no HTTP client is allowed to set — Host, Content-Length and the rest are computed by the runtime, so a test delivery cannot carry the value the signature is over. Real deliveries from a provider that sets such a header itself still verify.

Documentation validated against js-bao-wss-client 3.4.0 · js-bao 0.11.0 · primitive-admin 1.0.62 · primitive-app 3.1.0 — 2026-09-30