Skip to main content

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 a webhook-signature carrying one or more space-separated v1,<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.

Two keys, chosen by position

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 v1 example from the specification,
  • an asymmetric v1a Ed25519 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.