Skip to main content
Every outbound delivery is signed with the destination’s secret, a 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).
This page is about the deliveries Hookie sends. To have an endpoint check the signature a provider such as Stripe, GitHub or Shopify puts on the requests it receives, see Verify provider signatures.

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 second v1: t=<unix>,v1=<new>,v1=<previous>.
  • webhook-id, webhook-timestamp, webhook-signature: the Standard Webhooks set. webhook-id is the delivery ID, the same on every retry of that delivery (a replay is a new delivery, with a new ID); webhook-timestamp is the same unix time as t; webhook-signature is v1,<base64>, and for 24 hours after a rotation v1,<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.
Deliveries never follow redirects. A 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:
  1. Press Rotate on the destination (owner or admin). The new secret is shown to you.
  2. For the next 24 hours every delivery is signed with both secrets, new first: Hookie-Signature: t=1718000000,v1=<new>,v1=<previous> and webhook-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.
  3. Update your receiver to the new secret at any point in that window. The destination shows when the previous secret stops signing.
  4. 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.
Rotating again inside the window replaces the older of the two, so at most two secrets ever sign a delivery. A receiver that briefly needs to accept both can also hold both secrets and check each.

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.
To check one:
  1. Fetch Hookie’s key directory at https://hookie.ai/.well-known/http-message-signatures-directory. It is a JSON Web Key Set, served as application/http-message-signatures-directory+json.
  2. Find the key whose RFC 7638 thumbprint is the keyid. The keyid is the RFC 8037 thumbprint of the Ed25519 public key. Reject the request if no key matches.
  3. 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 of Signature-Input after sig1=.
  4. Verify the Ed25519 signature in Signature (the base64 between the colons) over that base with the key. Reject the request if expires has passed.
Hookie signs a delivery to a webhook destination only. Slack, S3 and SQS destinations are not signed this way, and deliveries from a preview or test environment are never signed, so a test deployment cannot pass as Hookie’s production sender. Each attempt of a delivery gets fresh headers, with its own 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.