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.

Either form selects Ed25519Scheme when the entry names no scheme. An entry that names a shared-secret scheme for them is refused as a configuration fault, because an HMAC under a public key is a signature anyone can compute. A raw base64 key without the whpk_ prefix cannot be told apart from a shared secret, so for one, name the scheme.

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.

A producer that sends the digest as Base64​

Shopify and WooCommerce sign the raw body with HMAC-SHA256 as SendCloud does, and write the digest as Base64 instead of hex. PlainHmacScheme reads that from config too: set signature_encoding to base64 beside the header name.

[
'name' => 'shopify',
'secret' => env('SHOPIFY_WEBHOOK_SECRET'),
'scheme' => \Pushery\Webhooks\Core\Signing\PlainHmacScheme::class,
'signature_headers' => ['signature' => 'X-Shopify-Hmac-Sha256'],
'signature_encoding' => 'base64',
],

WooCommerce is the same entry with X-WC-Webhook-Signature. Hex stays the default. A signature in the other encoding never matches, and the refusal looks exactly like a wrong secret, so a producer that is refused from its first delivery is worth checking for this before the secret. Only PlainHmacScheme reads the option: every other scheme's format is fixed by its dialect, and an entry that names an encoding for one is refused when the config loads.

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. A delivery signs with the secrets it was queued with for as long as it is retried; one that has to sign with the secrets of each attempt names a ResolvesDeliverySecrets class with ->resolveSecretsUsing(), as Sending shows.

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.