Signatures and interop
The default dialect is Standard Webhooks — byte-compatible with the specification and its official SDKs, so any Standard Webhooks consumer can verify our deliveries and we can verify theirs.
- Signed content:
{webhook-id}.{webhook-timestamp}.{rawBody}, HMAC-SHA256, base64-encoded. - Headers:
webhook-id,webhook-timestamp, and awebhook-signaturecarrying one or more space-separatedv1,<base64>entries (a rotation emits two — accept if either verifies). - Key derivation: strip an optional
whsec_prefix, then base64-decode the remainder to the raw HMAC key bytes.
The other schemes
Other schemes ship for interop: StripeScheme, GitHubScheme and PlainHmacScheme (receive
adapters for those producers), and the asymmetric Ed25519Scheme — the Standard Webhooks
v1a variant, which carries a webhook-signature: v1a,<base64> entry so a receiver only ever
holds the public key.
Generate a keypair with php artisan webhooks:ed25519-keygen or
Pushery\Webhooks\Core\Signing\Ed25519Keys::generate(). A receiver may pin a static public key or
point jwks.url at the producer's JSON Web Key Set of Ed25519 keys — fetched through the SSRF
guard and cached for jwks.cache_ttl seconds, one hour by default — for rotating provider
keys.
That default is the one number to check against your producer's rotation schedule. A producer
that rotates faster than the cache window signs with a key this side has not fetched yet, and an
unresolvable key is refused as unsigned — the same silent refusal the warning below describes.
Lower cache_ttl to below the producer's rotation interval, or pin kid and rotate the pin
yourself.
Without a pinned kid, the first two keys of the document are tried, in the order the
document publishes them. That is a statement about position, not about age: a JWK carries no
reliable age, and RFC 7517 defines no ordering for
keys. With one or two keys it makes no difference — both are tried either way. With three
or more, everything past the second is never tried, and a delivery signed with one of those
keys is refused as unsigned: no error, no log, just a producer retrying until its budget is
gone. Pin kid when the producer publishes more than two.
Which adapter to select per inbound source is on the Receiving page.
A producer with a different header name
A producer that uses a different header name needs no scheme class of its own: set
signature_headers.signature and it is injected into any header-overridable scheme (e.g.
PlainHmacScheme for SendCloud's Sendcloud-Signature).
A key you omit keeps the scheme's own default, so GitHubScheme keeps
X-Hub-Signature-256; StripeScheme's header is fixed.
Verification that is not a signature
Some providers can't be verified by a pure function of the bytes: Mollie signs nothing
(authenticity is an authenticated API call back to it), PayPal verifies through a cert-chain
API keyed on a webhook ID, not a secret. Point a config's verifier at a
Pushery\Webhooks\Client\Verification\InboundVerifier — see
Receiving.
Secret rotation
Secret rotation is first-class: Pushery\Webhooks\Core\Signing\SecretSet::rotating($current, $previous)
(or ->useSecrets() on a call) signs with both, so verification never breaks mid-rotation.
Canonical JSON (opt-in)
Set server.signing.canonicalize (or ->canonicalizeJson() per call) to sign and send a
deterministic, sorted-key body, so a receiver that re-canonicalizes reproduces the exact
signed bytes regardless of key order. Off by default — the exact bytes you send are already
what is signed.
Published interop vectors
Known-answer vectors are shipped at
resources/interop/standard-webhooks-vectors.json
(with a format guide)
so a third-party receiver — or a port of the verifier to another language — can prove
byte-for-byte compatibility without trusting this package's code.
They cover all three cases an implementer needs:
- the canonical symmetric
v1example from the specification, - an asymmetric
v1aEd25519 vector (public key, message, expected signature — Ed25519 signing is deterministic, so a correct port reproduces it exactly), and - negative vectors that must fail to verify, because an implementation that accepts everything also passes every positive test.
Tests in the suite re-verify each shipped vector against the engine, so the published contract can never drift.