> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tumban.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Signature verification

> Confirm a webhook payload was sent by Tumban, hasn't been tampered with, and is meant for your organization.

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

| Header                  | Meaning                                                                                                                         |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `X-Tumban-Signature`    | `sha256=<hex>` — HMAC-SHA256 over the raw request body. Simplest receiver implementation.                                       |
| `X-Tumban-Signature-V2` | `sha256=<hex>` — HMAC-SHA256 over `"{timestamp}.{org_id}.{body}"`. **Recommended** — adds replay resistance and tenant binding. |
| `X-Tumban-Timestamp`    | Unix seconds when the payload was signed.                                                                                       |
| `X-Tumban-Org-Id`       | The `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.

<Note>
  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.
</Note>

## Setting up

<Steps>
  <Step title="Generate a webhook secret">
    Call [Rotate webhook secret](/api/webhook-secret/rotate) (or use the
    dashboard — see that page). The value is shown exactly once.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Verify every incoming webhook">
    Use the V2 verifier below. It performs all three required checks:
    constant-time signature compare, replay window, and tenant binding.
  </Step>
</Steps>

## 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.

## Reference verifier (V2 — recommended)

<CodeGroup>
  ```python python theme={null}
  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)
  ```

  ```js node theme={null}
  import crypto from "node:crypto";

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


  export function verifyWebhook(headers, body) {
    const sigHeader = headers["x-tumban-signature-v2"] || "";
    const timestamp = headers["x-tumban-timestamp"]    || "";
    const orgId     = headers["x-tumban-org-id"]       || "";

    if (!sigHeader.startsWith("sha256=")) return false;
    const receivedSig = sigHeader.slice("sha256=".length);

    // 1. Tenant binding.
    if (orgId !== EXPECTED_ORG_ID) return false;

    // 2. Replay protection.
    const sentAt = parseInt(timestamp, 10);
    if (Number.isNaN(sentAt)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - sentAt) > TOLERANCE_SECONDS) {
      return false;
    }

    // 3. Constant-time signature check. `body` must be the raw bytes.
    const signedPayload = Buffer.concat([
      Buffer.from(`${timestamp}.${orgId}.`, "utf8"),
      body,
    ]);
    const expected = crypto
      .createHmac("sha256", WEBHOOK_SECRET)
      .update(signedPayload)
      .digest("hex");

    return crypto.timingSafeEqual(
      Buffer.from(expected, "hex"),
      Buffer.from(receivedSig, "hex"),
    );
  }
  ```

  ```ruby ruby theme={null}
  require "openssl"

  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, body)
    sig_header = headers["X-Tumban-Signature-V2"].to_s
    timestamp  = headers["X-Tumban-Timestamp"].to_s
    org_id     = headers["X-Tumban-Org-Id"].to_s

    return false unless sig_header.start_with?("sha256=")
    received_sig = sig_header.delete_prefix("sha256=")

    # 1. Tenant binding.
    return false unless org_id == EXPECTED_ORG_ID

    # 2. Replay protection.
    sent_at = Integer(timestamp) rescue nil
    return false if sent_at.nil?
    return false if (Time.now.to_i - sent_at).abs > TOLERANCE_SECONDS

    # 3. Constant-time signature check.
    signed_payload = "#{timestamp}.#{org_id}.".b + body
    expected = OpenSSL::HMAC.hexdigest("sha256", WEBHOOK_SECRET, signed_payload)

    Rack::Utils.secure_compare(expected, received_sig)
  end
  ```
</CodeGroup>

## 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.

```python theme={null}
# 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](/api/webhook-secret/rotate).

## 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](/api/webhook-secret/rotate) before trusting
webhook bodies in production.
