Webhook Signing Secrets

Every static webhook endpoint has its own signing secret. We use it to compute an HMAC-SHA256 over each event body. Your receiver verifies that HMAC to prove the event came from TapTree and was not tampered with in transit.

Headers on every delivery

Every delivery to a static endpoint carries these headers:

  • Name
    signature-algo
    Type
    header
    Description

    The signing algorithm. For static-endpoint v2 deliveries this is always hmac-sha256-v2. (Legacy per-payment webhooks may emit sha256 here.)

  • Name
    signature-method
    Type
    header
    Description

    Always HMAC for both v1 and v2 deliveries.

  • Name
    signature-timestamp
    Type
    header
    Description

    Unix-timestamp (seconds) of when we signed the event. Used to reject stale replays.

  • Name
    signature-secret-id
    Type
    header
    Description

    Public identifier of the secret version that signed this event — e.g. whsec_id_a3xq72k1 for static endpoints, or wh_BDgCjn9hJHR for legacy per-payment webhooks. During the rotation grace period, two valid IDs may circulate. You verify against the version matching this id.

  • Name
    signature
    Type
    header
    Description

    Hex-encoded HMAC-SHA256 of ${signature-timestamp}.${request_body_bytes} using the plaintext signing secret for the indicated signature-secret-id.

Verification steps

For every webhook request:

  1. Reject stale events: compute now() - signature-timestamp. Reject if more than 5 minutes (300 seconds) in the past or 60 seconds in the future.
  2. Look up the secret by id: match signature-secret-id to the active secret(s) on your side. If you don't recognise the id, reject the request.
  3. Compute the expected signature: hmac_sha256(secret, "${timestamp}.${raw_body_bytes}"). Use the raw HTTP body bytes — not a parsed-and-re-serialized JSON object, because key ordering and whitespace would differ.
  4. Constant-time compare: use hash_equals (PHP), crypto.timingSafeEqual (Node), hmac.compare_digest (Python). Never use === / == for HMAC comparison — it leaks timing information.

If the signature matches, you can trust the event body. If not, return 4xx so we mark the delivery as failed.

See Webhook Testing for working snippets in Node, PHP, Python and Ruby.

Rotation

Rotate a secret to invalidate the old one — for example, after a suspected compromise, on a regular schedule, or before offboarding an engineer who had access.

Rotation flow:

  1. Open the endpoint's row menu in the Dashboard and choose Rotate secret.
  2. We generate a fresh secret and a new signature-secret-id. Both are shown once.
  3. Copy the new secret into your receiver's secret store. Configure it to accept either the old or new secret during the grace period.
  4. After 24 hours, the old secret expires. Subsequent events carry only the new signature-secret-id. Remove the old secret from your store.

Why the plaintext is only shown once

Signing secrets are encrypted at rest. The plaintext is shown to you exactly once — in the Dashboard, at create or rotation time. Store it in your secret manager immediately. A database-level read does not yield usable signing material; if you lose the secret you must rotate to obtain a new one.

What to do if you lose the secret

  1. Open the endpoint in the Dashboard.
  2. Trigger Rotate secret (see above).
  3. Update your receiver with the new secret immediately.
  4. The previous secret expires after the 24-hour grace period. If you need a hard cutover, contact support and we can shorten the grace window.

There is no way to recover an old plaintext — by design. The reveal action in the Dashboard returns the current active secret only and is audit-logged for compliance.

Audit trail

Every secret operation — secret_revealed, rotated — is recorded in the endpoint's audit log. Reveal and rotation entries include the actor user id, IP address, user agent and timestamp. This is your forensic record if a secret leaks.

Was this page helpful?