Skip to main content
When your organization has a webhook secret configured, every webhook delivery includes four signed headers so your receiver can verify the payload came from Tumban, hasn’t been tampered with, and is meant for your organization.

Headers

HeaderMeaning
X-Tumban-Signaturesha256=<hex> — HMAC-SHA256 over the raw request body. Simplest receiver implementation.
X-Tumban-Signature-V2sha256=<hex> — HMAC-SHA256 over "{timestamp}.{org_id}.{body}". Recommended — adds replay resistance and tenant binding.
X-Tumban-TimestampUnix seconds when the payload was signed.
X-Tumban-Org-IdThe org_id this webhook is for.
Both signatures use the same secret. The signed payload is built from raw bytes, not a decoded string — the V2 string-to-sign is the UTF-8 encoding of "{timestamp}.{org_id}." concatenated with the request body bytes. Verify before parsing JSON, since any whitespace or key reordering you apply will change the bytes.
Under rare error paths Tumban sends X-Tumban-Org-Id: "" (empty string) — this happens when the worker that fired the webhook lost its organization context. The V2 verifier above already rejects these payloads because the empty header value will not match EXPECTED_ORG_ID. Treat any webhook with an empty X-Tumban-Org-Id as suspicious.

Setting up

1

Generate a webhook secret

Call Rotate webhook secret (or use the dashboard — see that page). The value is shown exactly once.
2

Store the secret

Put it in your secret manager. Capture the org_id your receiver expects to receive webhooks for at the same time — you’ll bind the receiver to it.
3

Verify every incoming webhook

Use the V2 verifier below. It performs all three required checks: constant-time signature compare, replay window, and tenant binding.

What your verifier must do

A correct verifier performs three checks in addition to recomputing the signature:
  1. Constant-time signature compare. Do not use == on the hex digest. Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node), or your language’s equivalent. A naive == leaks timing information that lets an attacker brute-force a valid signature byte by byte.
  2. Replay protection. Reject anything whose X-Tumban-Timestamp is more than ~5 minutes from now. Captured signed payloads should not be replayable indefinitely.
  3. Tenant binding. Verify X-Tumban-Org-Id matches the org_id your receiver expects. Without this, a webhook signed with org A’s secret can be delivered to a receiver that hardcodes one secret and accepts any “from us” payload — a cross-tenant abuse vector.
import hashlib
import hmac
import time

WEBHOOK_SECRET   = "..."          # the secret from Rotate webhook secret
EXPECTED_ORG_ID  = "org_abc123"   # the org_id this receiver belongs to
TOLERANCE_SECONDS = 300           # 5 minutes


def verify_webhook(headers: dict, body: bytes) -> bool:
    """Return True iff headers + body authenticate as a valid Tumban
    webhook for EXPECTED_ORG_ID, signed within TOLERANCE_SECONDS.
    """
    sig_header = headers.get("X-Tumban-Signature-V2", "")
    timestamp  = headers.get("X-Tumban-Timestamp", "")
    org_id     = headers.get("X-Tumban-Org-Id", "")

    if not sig_header.startswith("sha256="):
        return False
    received_sig = sig_header[len("sha256="):]

    # 1. Tenant binding — reject anything not for our org.
    if org_id != EXPECTED_ORG_ID:
        return False

    # 2. Replay protection — reject stale or future timestamps.
    try:
        sent_at = int(timestamp)
    except ValueError:
        return False
    if abs(int(time.time()) - sent_at) > TOLERANCE_SECONDS:
        return False

    # 3. Constant-time signature check.
    signed_payload = f"{timestamp}.{org_id}.".encode() + body
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed_payload,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(received_sig, expected)

Choosing between V1 and V2

  • V2 (X-Tumban-Signature-V2) is recommended for all new receivers. It binds the signature to a specific tenant and timestamp, defending against replay and cross-tenant abuse.
  • V1 (X-Tumban-Signature) is shipped on every delivery for receivers that need the simplest possible HMAC-over-body implementation. It does not bind tenant or timestamp — if you use it, rotate secrets aggressively and never share a secret across orgs.
# V1-only verifier (for completeness; prefer V2):
def verify_v1(secret: str, body: bytes, header_value: str) -> bool:
    if not header_value.startswith("sha256="):
        return False
    expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    received = header_value[len("sha256="):]
    return hmac.compare_digest(expected, received)

Rotation

When you rotate the secret, Tumban switches over immediately — incoming webhooks are signed with the new secret only. Update your verification code first, then call Rotate webhook secret.

When the headers are absent

If your organization has no webhook secret configured, Tumban does not send any of the four signature headers. Generate one with Rotate webhook secret before trusting webhook bodies in production.