Staying in sync

Webhooks

The same events as the change feed, pushed to your URL, signed, and retried for a day. Verify the signature before you parse the body.

Verify the signature first

Every delivery carries a Tahoe-Signature header:

Delivery headers
POST /hooks/tahoe HTTP/1.1
Content-Type: application/json
Tahoe-Signature: t=1789245645,v1=6d8b2f...c41a
Tahoe-Partner-Api-Version: 2026-09-09
PropertyValue
HeaderTahoe-Signature
Formatt=<unix seconds>,v1=<hex>[,v1=<hex>]
AlgorithmHMAC-SHA256 over the literal string {t}.{raw_body}
Tolerance300 seconds

Three rules, and each one is load-bearing:

  1. Sign the raw bytes. Not a re-serialised object. Any JSON library that reorders keys or changes spacing will produce a different digest and every delivery will fail verification.
  2. Compare in constant time. A naive == on strings leaks the correct digest a byte at a time to anyone who can measure your response.
  3. Two v1 values can appear during a secret rotation. Accept the delivery if either verifies, otherwise every rotation is an outage.
Verification (Python)
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(raw_body: bytes, header: str, secret: str) -> bool:
    """True if this delivery really came from Tahoe and is recent.

    `raw_body` MUST be the bytes as received. Re-serialising the parsed JSON
    changes key order and whitespace, and the digest with it.
    """
    # Parsed manually rather than into a dict: there can be MORE THAN ONE v1
    # during a secret rotation, and a dict would keep only the last of them.
    timestamp = None
    signatures = []
    for piece in header.split(","):
        key, _, value = piece.strip().partition("=")
        if key == "t":
            timestamp = value
        elif key == "v1":
            signatures.append(value)

    if not timestamp or not signatures:
        return False

    try:
        stamp = int(timestamp)
    except ValueError:
        return False

    # Reject a replay of an old, validly signed body.
    if abs(int(time.time()) - stamp) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{stamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()

    # compare_digest, never ==: a byte-by-byte comparison leaks the digest.
    return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
Verification (TypeScript)
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

export function verify(rawBody: Buffer, header: string, secret: string): boolean {
    let timestamp: string | null = null;
    // More than one v1 arrives during a secret rotation; keep them all.
    const signatures: string[] = [];

    for (const piece of header.split(',')) {
        const [key, value] = piece.trim().split('=', 2);
        if (key === 't') timestamp = value;
        else if (key === 'v1') signatures.push(value);
    }
    if (!timestamp || signatures.length === 0) return false;

    const stamp = Number(timestamp);
    if (!Number.isFinite(stamp)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - stamp) > TOLERANCE_SECONDS) return false;

    const expected = createHmac('sha256', secret)
        .update(`${stamp}.`)
        .update(rawBody)
        .digest('hex');

    return signatures.some((candidate) => {
        // timingSafeEqual throws on a length mismatch, so guard first.
        if (candidate.length !== expected.length) return false;
        return timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
    });
}

The body

Identical to a change-feed row — same object: "event" envelope, same thin payload, same sequence. So one handler can serve both a webhook and a catch-up poll, and you should write it that way.

Request body
{
  "object": "event",
  "id": "evt_7Kd2mXq4Rp8v",
  "type": "application.stage_changed",
  "api_version": "2026-09-09",
  "created_at": "2026-09-08T11:20:45.331Z",
  "sequence": 48214,
  "workspace_id": "wsp_4Kd8sPm2Qx7L",
  "data": {
    "object": {
      "object": "application",
      "id": "app_6Qm2xKd4Rp8v",
      "workspace_id": "wsp_4Kd8sPm2Qx7L",
      "stage_id": "stg_3Rp8vKd2mXq4",
      "updated_at": "2026-09-08T11:20:45.331Z"
    },
    "previous_attributes": { "stage_id": "stg_9Kd2mXq4Rp8v" }
  },
  "links": { "self": "/api/partner/v1/events/evt_7Kd2mXq4Rp8v" }
}

Responding

Return 2xx as soon as you have durably queued the event, and do the work afterwards. Anything else is retried.

  • Do not process synchronously. Tahoe times deliveries out; slow handlers turn into retries, and retries turn into duplicates.
  • Redirects are not followed. A 301 or 302 is a failure, not a hop — point Tahoe at the final URL.
  • Send Retry-After on a 429 or 503 and Tahoe will honour it instead of using its own backoff.

The retry schedule

Ten attempts over roughly two days, then the delivery is abandoned:

Backoff between attempts
10s 30s 2m 10m 1h 6h 12h 24h 24h

Delivery is at-least-once: a timeout after your handler succeeded still counts as a failure and will be retried. Deduplicate on the event id and keep handlers idempotent.

GET/webhooks/endpointswebhooks:read

Response
{
  "object": "list",
  "data": [
    {
      "object": "webhook_endpoint",
      "id": "8f2c1a94-...",
      "url": "https://hooks.workonward.com/tahoe",
      "description": "HRIS sync",
      "event_types": [],
      "enabled": true,
      "disabled_at": null,
      "disabled_reason": null,
      "signing_secret_version": 2,
      "signing_secret_rotated_at": "2026-08-30T09:00:00.000Z",
      "consecutive_failures": 0,
      "last_success_at": "2026-09-09T09:58:12.004Z",
      "last_failure_at": "2026-08-14T02:11:40.882Z",
      "created_at": "2026-07-01T09:00:00.000Z",
      "signature_scheme": {
        "header": "Tahoe-Signature",
        "format": "t=<unix seconds>,v1=<hex>[,v1=<hex>]",
        "algorithm": "HMAC-SHA256 over \"{t}.{raw_body}\"",
        "tolerance_seconds": 300,
        "note": "Two v1 values appear during a secret rotation. Accept the delivery if EITHER verifies."
      }
    }
  ],
  "has_more": false,
  "next_cursor": null
}

An empty event_types means every type — the only sane default for a feed that gains types over time.

Note what is not here: the signing secret. You get signing_secret_version, which increments on rotation. The secret itself is shown once, in the dashboard, at the moment it is rotated.

GET/webhooks/endpoints/{endpoint_id}webhooks:read

One endpoint, same shape.

GET/webhooks/deliverieswebhooks:read

The delivery log — what was sent, what came back, and what is queued for another attempt. This is where you look when someone says “we never got that event”.

Response
{
  "object": "list",
  "data": [
    {
      "object": "webhook_delivery",
      "id": "c4e7...",
      "endpoint_id": "8f2c1a94-...",
      "event_id": "evt_7Kd2mXq4Rp8v",
      "event_type": "application.stage_changed",
      "status": "failed",
      "attempt": 3,
      "response_status": 502,
      "error": "upstream returned 502",
      "next_attempt_at": "2026-09-09T10:24:00.000Z",
      "delivered_at": null,
      "created_at": "2026-09-08T11:20:46.112Z"
    }
  ],
  "has_more": true,
  "next_cursor": "cur_eyJrIjoiMjAyNi0wOS0wOFQxMToyMDo0Nloi..."
}

error is event_expired when a delivery exhausted its attempts. Those events are still in the change feed — the push failed, the log did not lose them.

Hardening the receiver

  • Verify before you parse. Treat the body as untrusted bytes until the signature checks out.
  • Do not trust the payload as data. It is thin on purpose — read the resource by handle so what you get reflects your scopes now, and so a replayed old delivery cannot resurrect data that has since been erased.
  • Rate-limit your own endpoint. A bulk import in a customer’s workspace produces a burst.
  • Keep the change feed as your backstop. Webhooks are an optimisation over polling; the ordered log is the source of truth, and a consumer that can only be pushed to has no way to recover from a gap.