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:
POST /hooks/tahoe HTTP/1.1
Content-Type: application/json
Tahoe-Signature: t=1789245645,v1=6d8b2f...c41a
Tahoe-Partner-Api-Version: 2026-09-09| Property | Value |
|---|---|
Header | Tahoe-Signature |
Format | t=<unix seconds>,v1=<hex>[,v1=<hex>] |
Algorithm | HMAC-SHA256 over the literal string {t}.{raw_body} |
Tolerance | 300 seconds |
Three rules, and each one is load-bearing:
- 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.
- Compare in constant time. A naive
==on strings leaks the correct digest a byte at a time to anyone who can measure your response. - Two
v1values can appear during a secret rotation. Accept the delivery if either verifies, otherwise every rotation is an outage.
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)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.
{
"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-Afteron 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:
10s → 30s → 2m → 10m → 1h → 6h → 12h → 24h → 24hDelivery 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
{
"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”.
{
"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.
