whsec_-prefixed value that Hookie generates when you create the destination. Each delivery is signed twice: with Hookie’s own Hookie-Signature header, and with the Standard Webhooks headers, so any Standard Webhooks library can verify it. Verify either one before trusting the payload.
You never have to write it down. The secret is encrypted at rest and masked in the console. It is shown in full when you create the destination, and owners and admins can reveal it again at any time: Reveal secret, the eye button on the destination’s Signing secret row, fetches it on demand for them only, and every reveal is written to your audit log. Rotate issues a new secret and shows it to you. The secret it replaces keeps signing for 24 hours: every delivery in that window carries two signatures, one per secret, so your receiver keeps verifying while you update it (see Rotating the secret).
The headers
Each delivery carries:Hookie-Signature:t=<unix>,v1=<hex>, an HMAC-SHA256 (Stripe-style). For 24 hours after a rotation it carries a secondv1:t=<unix>,v1=<new>,v1=<previous>.webhook-id,webhook-timestamp,webhook-signature: the Standard Webhooks set.webhook-idis the delivery ID, the same on every retry of that delivery (a replay is a new delivery, with a new ID);webhook-timestampis the same unix time ast;webhook-signatureisv1,<base64>, and for 24 hours after a rotationv1,<new> v1,<previous>, space-separated.Hookie-Event-Id: the event ID. Dedupe on this, since delivery is at-least-once and unordered.Hookie-Delivery-Id: the delivery, useful in logs. It stays the same across the retries of one delivery (every attempt is listed under it in the console’s delivery drawer), and a replay is a new delivery with a new ID.Hookie-Hop: how many times this event has passed through Hookie. If your service forwards the event into Hookie again, pass this header on: Hookie refuses an event at 8 hops, which stops a delivery loop.
3xx answer is recorded as a failed attempt naming the Location it pointed to, and is not retried: a redirect means the destination URL is wrong, so update it rather than letting a signed event be forwarded somewhere you did not choose.
Two schemes, one secret
You only need to verify one of them.Hookie-Signature is unchanged, so an existing receiver keeps working; the Standard Webhooks headers let you use an off-the-shelf library instead.
If the destination has a body template (see Destination options), both signatures cover the templated body as sent, not the envelope. Verification is the same.
Both are computed from the same signing secret, but each derives its HMAC key
the way its own scheme says, so the keys differ. Hookie’s secrets are
whsec_
followed by 48 hexadecimal characters. Hex digits are all base64 characters and
48 is a multiple of 4, so the part after whsec_ is already valid base64: it
decodes to 36 bytes, and every Standard Webhooks library accepts the secret
exactly as Hookie shows it. Do not hex-decode it, and do not use the text of
it as the Standard Webhooks key: base64-decode the characters after whsec_.
For either scheme: verify the exact raw body bytes, compare in constant time with each signature and accept the request if any of them matches, and reject a timestamp more than five minutes from now.
Verify Hookie-Signature
The signed string is<t>.<raw body>: the timestamp, a dot, then the exact request body bytes. Compute HMAC-SHA256(secret, "<t>.<body>") as lowercase hex, keyed with the whole secret string, and compare it with each v1 in the header.
Verify Standard Webhooks
The signed string is<webhook-id>.<webhook-timestamp>.<raw body>. Compute its HMAC-SHA256 as base64, keyed with the base64-decoded bytes after whsec_, and compare it with the signature after v1, in each space-separated entry of webhook-signature.
Any Standard Webhooks library verifies these
headers with the secret as-is: standardwebhooks on npm, PyPI and RubyGems, and
github.com/standard-webhooks/standard-webhooks/libraries/go for Go. For example,
in Node: new Webhook(secret).verify(rawBody, headers) (from standardwebhooks),
which throws when no signature matches.
Or, without a library:
Rotating the secret
Rotating is safe to do while deliveries are flowing:- Press Rotate on the destination (owner or admin). The new secret is shown to you.
- For the next 24 hours every delivery is signed with both secrets, new first:
Hookie-Signature: t=1718000000,v1=<new>,v1=<previous>andwebhook-signature: v1,<new> v1,<previous>. A receiver that accepts any matching signature (as the verifiers above and every Standard Webhooks library do) keeps working whichever secret it holds. - Update your receiver to the new secret at any point in that window. The destination shows when the previous secret stops signing.
- Once every receiver is on the new secret you can press Expire now to stop sending the previous signature early; otherwise it stops on its own after 24 hours.
Express handler
Check that Hookie sent it (Web Bot Auth)
Production deliveries to a webhook destination also carry three headers from the Web Bot Auth scheme, which is HTTP Message Signatures (RFC 9421) with an Ed25519 key. They prove the request came from Hookie’s bot. They do not prove the body is intact: use the payload signatures above for that.- Fetch Hookie’s key directory at
https://hookie.ai/.well-known/http-message-signatures-directory. It is a JSON Web Key Set, served asapplication/http-message-signatures-directory+json. - Find the key whose RFC 7638 thumbprint is the
keyid. Thekeyidis the RFC 8037 thumbprint of the Ed25519 public key. Reject the request if no key matches. - Rebuild the signature base, three lines joined by newlines:
"@authority": <the host you received the request on>, then"signature-agent": "https://hookie.ai"(the header’s value, quotes included), then"@signature-params":followed by the value ofSignature-Inputaftersig1=. - Verify the Ed25519 signature in
Signature(the base64 between the colons) over that base with the key. Reject the request ifexpireshas passed.
created, expires and nonce. If a destination sends its own Signature, Signature-Input or Signature-Agent header, Hookie sends none of the three on that destination’s deliveries. The partner’s header goes out unchanged, and those deliveries are not signed this way.
Notes
- Verify against the raw bytes. Re-serializing the JSON will change the signature.
- Reject the request if the headers are missing or no signature matches. Reject a timestamp more than five minutes old to limit replay.
- Always compare with a constant-time function:
crypto.timingSafeEqual(Node),hmac.compare_digest(Python),hmac.Equal(Go),OpenSSL.secure_compare(Ruby). - A destination’s URL is fixed once created, and a signature is only meaningful against the URL it was sent to. To move a destination, create the replacement and delete the old one.