Webhooks
Register endpoints, receive changes as signed HTTP requests, and how retries work.
A webhook sends each event from the change feed to a URL on your server as an HTTPS POST, signed so you can check it came from Tahoe. The body is the same event object the feed returns, so one handler can serve both.
Webhooks make you faster, not more correct. Deliveries can fail and endpoints can be switched off, so keep a change-feed reader as your backstop and recover from the feed when a delivery goes missing.
Set up an endpoint
You register an endpoint yourself, with an API call that holds the webhooks:write scope. An endpoint belongs to the key that created it, and it receives only events for the workspace the request named and only the event types it lists.
Prepare an HTTPS URL
For example https://app.example.com/webhooks/tahoe. It must follow the rules for the URL below, and it must answer the request itself: redirects are not followed.
Create the endpoint
Call POST /webhooks/endpoints with the URL and the event types you want. You need a key that holds webhooks:write and the read scope of every event type you ask for.
Store the signing secret
The response carries signing_secret once. Keep it on your server with your API key. Tahoe stores it encrypted and cannot show it again. If you lose it, call rotate_secret for a new one.
Send a test
Call POST /webhooks/endpoints/{endpoint_id}/test. Tahoe queues a ping event for the endpoint, and you read the outcome from the delivery log.
What a delivery looks like
POST /webhooks/tahoe HTTP/1.1
Host: app.example.com
Content-Type: application/json
User-Agent: Tahoe-Webhooks/1.0
Tahoe-Signature: t=1788866643,v1=6d8b2f9e...c41a
Tahoe-Event-Id: evt_2Wp5nRc8Kd3x
Tahoe-Delivery-Id: 4b1d7e2a-9c3f-4e8a-b6d5-2f7c1a9e0b34
Tahoe-Delivery-Attempt: 1
Tahoe-Api-Version: 2026-09-09| Header | What it holds |
|---|---|
Tahoe-Signature | The signature. Check it before anything else. |
Tahoe-Event-Id | The event’s id. Use it to skip an event you already handled. |
Tahoe-Delivery-Id | This delivery. It matches id in the delivery log. |
Tahoe-Delivery-Attempt | 1 for the first try, then counts up on each retry. |
Tahoe-Api-Version | The API version the event was shaped by. |
{
"object": "event",
"id": "evt_2Wp5nRc8Kd3x",
"type": "application.stage_changed",
"api_version": "2026-09-09",
"created_at": "2026-09-08T11:24:02.907Z",
"sequence": 48219,
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"data": {
"object": {
"object": "application",
"id": "app_6Qm2xKd4Rp8v",
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"updated_at": "2026-09-08T11:24:02.880Z",
"stage_id": "stg_3Rp8vKd2mXq4",
"status": "in_review",
"changed": ["stage_id"]
},
"previous_attributes": { "stage_id": "stg_9Kd2mXq4Rp8v" }
},
"links": { "self": "/api/partner/v1/events/evt_2Wp5nRc8Kd3x" }
}The body is exactly a row of the change feed: the same envelope, the same thin payload and the same sequence. Write one handler and call it from both your webhook route and your feed reader.
Verify the signature
| Property | Value |
|---|---|
| Header | Tahoe-Signature |
| Format | t=<unix seconds>,v1=<hex>[,v1=<hex>] |
| Algorithm | HMAC-SHA256 with your signing secret, over the string {t}.{raw_body} |
| Tolerance | 300 seconds between t and your clock |
Three rules, and each one matters:
- Sign the raw bytes. Use the body exactly as it arrived, not a parsed and re-encoded copy. A JSON library that reorders keys or changes spacing produces a different digest, and every delivery fails.
- Compare in constant time. A plain
==on strings can reveal the right digest one byte at a time to anyone who can time your responses. - Accept either
v1. After the signing secret is rotated, the header carries onev1for the new secret and one for the previous secret. Accept the delivery if either matches, so a rotation never becomes 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 came from Tahoe and is recent.
raw_body must be the bytes exactly as received. Parsing and re-encoding
the JSON changes the bytes, and the signature with them.
"""
timestamp = None
signatures = []
# Keep every v1: after a secret rotation there are two of them, and a
# dict would keep only the last one.
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
# Refuse a replay of an old delivery that was validly signed.
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 ==: it takes the same time whatever the input.
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)import json
import os
from flask import Flask, abort, request
from verify import verify
from queue_client import enqueue # your own durable queue
app = Flask(__name__)
SECRET = os.environ["TAHOE_WEBHOOK_SECRET"]
@app.post("/webhooks/tahoe")
def tahoe_webhook():
raw = request.get_data() # the exact bytes, before any parsing
if not verify(raw, request.headers.get("Tahoe-Signature", ""), SECRET):
abort(400)
enqueue(json.loads(raw)) # store it, then answer; do the work elsewhere
return "", 204import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: Buffer, header: string, secret: string): boolean {
let timestamp: string | null = null;
// Keep every v1: after a secret rotation there are two of them.
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.isInteger(stamp)) return false;
// Refuse a replay of an old delivery that was validly signed.
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 when the lengths differ, so check first.
if (candidate.length !== expected.length) return false;
return timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
});
}import express from 'express';
import { enqueue } from './queue'; // your own durable queue
import { verify } from './verify';
const app = express();
// express.raw keeps the exact bytes. express.json() would parse them away,
// and the signature could no longer be checked.
app.post('/webhooks/tahoe', express.raw({ type: 'application/json' }), async (req, res) => {
const header = req.get('Tahoe-Signature') ?? '';
if (!verify(req.body, header, process.env.TAHOE_WEBHOOK_SECRET ?? '')) {
res.status(400).end();
return;
}
await enqueue(JSON.parse(req.body.toString('utf8'))); // store it, then answer
res.status(204).end();
});Respond quickly
Return a 2xx as soon as you have stored the event somewhere durable, and do the work afterwards. Anything else counts as a failed attempt and is retried.
- 10-second timeout. Tahoe waits 10 seconds for your answer. A slow handler turns into a retry, and a retry into a duplicate.
- Redirects are failures. A
301or302is not followed. Register the final URL. 410 Goneswitches the endpoint off. Tahoe takes it as “this endpoint is retired” and disables the endpoint at once. Send it only when you mean that.Retry-Afteris honored. If your response carriesRetry-Afterin seconds, Tahoe waits for the longer of its own next delay and yours, up to 24 hours.
Retries
Each event gets up to 10 attempts. After a failed attempt, Tahoe waits as shown below before the next one. From the first attempt to the last is about 2.8 days. After the tenth failure, the delivery is marked failed and no more attempts are made.
| Attempt | Sent |
|---|---|
| 1 | Shortly after the event is recorded |
| 2 | 10 seconds after attempt 1 fails |
| 3 | 30 seconds later |
| 4 | 2 minutes later |
| 5 | 10 minutes later |
| 6 | 1 hour later |
| 7 | 6 hours later |
| 8 | 12 hours later |
| 9 | 24 hours later |
| 10 | 24 hours later |
Delivery is at least once. If your handler succeeds but the answer does not reach Tahoe in time, the attempt counts as failed and the event arrives again. Skip any event id you have already handled, and keep handlers safe to run twice.
When an endpoint is disabled
Read your endpoints
GET/webhooks/endpointswebhooks:read
Lists the endpoints registered for the key you call with, newest first.
curl https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "webhook_endpoint",
"id": "8f2c1a94-5d3e-4b7a-9c61-0e2f4d8b7a13",
"url": "https://app.example.com/webhooks/tahoe",
"description": "Acme 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_typesmeans every type, including types added later. An endpoint you create yourself always lists at least one type, so it never starts receiving a type you did not choose. - A read never returns the signing secret. You see
signing_secret_version, which goes up by one on each rotation, andsigning_secret_rotated_at. The secret is shown once, when you create an endpoint or rotate its secret.
GET/webhooks/endpoints/{endpoint_id}webhooks:read
One endpoint, in the same shape. An endpoint that belongs to another key answers 404.
Manage your endpoints
Five POST calls create, change, rotate, disable and test an endpoint. All need webhooks:write and an API key that belongs to a Tahoe user. A Sign in with Tahoe token gets 403 write_requires_api_key, unless writes for connected apps are switched on. webhooks:write is one of the paid plan scopes. None of these calls emits an event, and nothing here sends anything to a candidate or a recruiter: webhooks go only to the URL you registered. A malformed endpoint ID, an endpoint of another key and an endpoint of another workspace all return 404 not_found.
Rules for the URL
A webhook makes Tahoe’s network send a signed request to an address you chose, so the address is checked hard, when you register it and again before every delivery.
- It must be
https, at most 2,048 characters, with no user name or password and no fragment. The port is 443 (the default) or 8443. - The host must be a DNS name, not an IP address and not
localhost. The name must resolve, and every address it resolves to must be public. A private, loopback, link-local, carrier-grade NAT, multicast, reserved, documentation or unique-local address refuses the whole name, and so does an IPv4 address written as an IPv6 one. - Before every delivery Tahoe resolves the name again and connects to an address it has just checked, while verifying the TLS certificate against your host name. If a name starts resolving to a non-public address, deliveries fail with
url_refused: blocked_addressand follow the normal retry schedule. - Redirects are not followed. A 3xx answer is a failed delivery.
Limits
A key may have 10 enabled endpoints, and a workspace may have 20 enabled endpoints across all its keys. A key may have 100 endpoints in total, including disabled ones. Disabled endpoints are kept and listed, never deleted, so they do not count toward the first two limits. A key cannot have two endpoints with the same URL in one workspace.
Which event types an endpoint may ask for
event_types is required: 1 to 40 names, each from the event catalogue. An endpoint may subscribe only to an event type whose read scope your key holds: job.* needs jobs:read, application.* needs applications:read, list.* and list_membership.* need lists:read, and so on. The list cannot be empty, so an endpoint never starts receiving an event type added after it was created.
The scopes an endpoint may receive are fixed when it is created or when its event types change. Removing a scope from a key later does not narrow an existing endpoint. Disable it and create it again if a key’s scopes shrink.
POST/webhooks/endpointswebhooks:write
Registers an endpoint and returns 201 with the endpoint and its signing secret.
| Field | Type | Notes |
|---|---|---|
url | string, required | Up to 2,048 characters. See the rules above. |
event_types | string[], required | 1 to 40 event types. |
description | string | Optional, at most 200 characters. |
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.acme.example/tahoe",
"event_types": ["job.published", "job.closed"],
"description": "Production sync"
}'{
"object": "webhook_endpoint",
"id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
"url": "https://hooks.acme.example/tahoe",
"description": "Production sync",
"event_types": ["job.published", "job.closed"],
"enabled": true,
"disabled_at": null,
"disabled_reason": null,
"signing_secret_version": 1,
"signing_secret_rotated_at": "2026-10-08T12:00:00.000Z",
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"created_at": "2026-10-08T12:00:00.000Z",
"signature_scheme": { "header": "Tahoe-Signature", "...": "..." },
"workspace_id": "wsp_4Kd8sPm2Qx7L",
"signing_secret": "whsec_..."
}signing_secretiswhsec_followed by 43 URL-safe characters. It appears once. The response carriesCache-Control: no-storeand is never kept for replay, so anIdempotency-Keydoes not help here. A retry runs again and meets409 webhook_endpoint_existsif the first call succeeded. In that case callrotate_secretto get a secret.- Signatures use the header described under Verify the signature.
POST/webhooks/endpoints/{endpoint_id}/updatewebhooks:write
Changes an endpoint. endpoint_id is the id from create. Send any of url, event_types, description and enabled, and at least one. url, event_types and enabled cannot be null, and "description": null clears the description. The response is the endpoint without a secret.
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/update \
-H "Authorization: Bearer $TAHOE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "event_types": ["job.published"], "enabled": true }'- A new
urlgoes through the same checks as create and resetsconsecutive_failures. A newevent_typeslist replaces the old one and is checked against your scopes. "enabled": falseis the same asdisable."enabled": trueturns an endpoint back on, including one Tahoe disabled after repeated failures or a410. It clearsdisabled_atanddisabled_reason, resetsconsecutive_failures, checks the URL again and counts against the limits again.- The signing secret never changes here.
POST/webhooks/endpoints/{endpoint_id}/rotate_secretwebhooks:write
Issues a new signing secret. The body is empty. The response is the endpoint with signing_secret_version increased by one, the new signing_secret, and previous_secret_valid_until.
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/rotate_secret \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "webhook_endpoint",
"id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
"signing_secret_version": 2,
"...": "...",
"signing_secret": "whsec_...",
"previous_secret_valid_until": "2026-10-09T12:00:00.000Z"
}- The old secret keeps signing for 24 hours, so deliveries carry two
v1values and a receiver can deploy the new secret without dropping events. Rotating again inside that window ends the earlier secret’s overlap, so deploy the new secret before you rotate again. - An endpoint that Tahoe registered for you moves onto a stored secret with its first rotation, and its old secret gets the same 24 hour overlap.
- Like create, the response is not kept for replay. A repeated call issues another new secret.
POST/webhooks/endpoints/{endpoint_id}/disablewebhooks:write
Stops sending events to the endpoint. The body is empty. The response is the endpoint with enabled: false, disabled_at set and disabled_reason: "disabled_by_customer".
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/disable \
-H "Authorization: Bearer $TAHOE_API_KEY"Deliveries still waiting for it, pending or in flight, are marked failed with the error endpoint_disabled, so they do not arrive late if you turn it on again. Nothing is deleted: the endpoint, its secret and its delivery log stay, and the events it missed can be read from the change feed. Disabling a disabled endpoint succeeds and changes nothing.
POST/webhooks/endpoints/{endpoint_id}/testwebhooks:write
Queues one ping event for this endpoint only and returns the delivery that carries it. The body is empty. This call is in the expensive rate-limit tier, because it causes an outbound request.
curl -X POST https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints/6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44/test \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "webhook_test",
"endpoint_id": "6f1c1c1e-3b7a-4d52-8c0e-9a1f5e2d7b44",
"delivery_id": "0b5d9a0e-6c2f-4e1b-9d73-4a8e1f6c2b90",
"event_type": "ping",
"status": "pending"
}- The ping goes through the real delivery path: signed, address checked, sent, retried on failure and logged. So it is sent within about a minute, not during the request, and a
200from your receiver means the whole path works. Read the outcome fromGET /webhooks/deliveries?endpoint_id=....delivery_idis theidof that delivery. - The body is the normal event envelope with
type: "ping"and adata.objectof{"endpoint_id": "..."}. It is kept for four days and appears inGET /eventsonly for keys that holdwebhooks:writein the same workspace. - One test per endpoint per 10 seconds. A repeat with the same
Idempotency-Keyreturns the samedelivery_idand queues nothing new.
Errors from these calls
| Status | Code | What to do |
|---|---|---|
| 400 | invalid_request | The body failed validation: a missing or empty field, a value that is too long, more than 40 event types, an unknown field, or an empty update. |
| 400 | invalid_event_type | A name is not in the event catalogue. param is event_types. |
| 400 | event_type_not_permitted | The key does not hold the read scope of that event type. The message names the type and the scope. |
| 400 | webhook_url_not_allowed | The URL breaks a rule above, does not resolve, or resolves to a non-public address. param is url. |
| 403 | webhooks_self_serve_disabled | Registering endpoints through the API is not switched on yet. Email [email protected]. |
| 403 | insufficient_scope | The key lacks webhooks:write. |
| 403 | write_requires_api_key | The credential is a Sign in with Tahoe token, or is not tied to a Tahoe user. |
| 404 | not_found | No such endpoint for this key and workspace. |
| 409 | webhook_endpoint_exists | This key already has an endpoint with that URL in this workspace. Change or turn on that one. |
| 409 | webhook_endpoint_limit_reached | One of the three limits above. Disable an endpoint first. |
| 409 | webhook_endpoint_disabled | Test only. Turn the endpoint on first. |
| 429 | webhook_test_throttled | Test only. One test per endpoint per 10 seconds. Wait for Retry-After. |
| 429 | webhook_secrets_unavailable | Tahoe cannot create or read signing secrets right now. Nothing was changed. Retry later. |
| 429 | webhooks_delivery_disabled | Test only. Webhook delivery is switched off, so a ping would never be sent. |
Webhook delivery itself has to be on for events to flow. Creating endpoints works either way.
The delivery log
GET/webhooks/deliverieswebhooks:read
What was sent, what came back, and what is waiting for another attempt. Look here first when someone says “we never got that event”. The log returns the most recent deliveries, newest first. It is not paged, so narrow it with the filters.
| Parameter | Notes |
|---|---|
endpoint_id | Only deliveries to this endpoint. |
event_id | Only deliveries of this event (evt_ handle). |
status | pending, sending, delivered or failed. |
limit | Default 25, maximum 100. |
curl "https://tahoe.workonward.com/api/partner/v1/webhooks/deliveries?status=pending&limit=50" \
-H "Authorization: Bearer $TAHOE_API_KEY"{
"object": "list",
"data": [
{
"object": "webhook_delivery",
"id": "4b1d7e2a-9c3f-4e8a-b6d5-2f7c1a9e0b34",
"endpoint_id": "8f2c1a94-5d3e-4b7a-9c61-0e2f4d8b7a13",
"event_id": "evt_2Wp5nRc8Kd3x",
"event_type": "application.stage_changed",
"status": "pending",
"attempt": 3,
"response_status": 502,
"error": null,
"next_attempt_at": "2026-09-08T11:27:12.480Z",
"delivered_at": null,
"created_at": "2026-09-08T11:24:03.112Z"
}
],
"has_more": false,
"next_cursor": null
}| status | Meaning |
|---|---|
pending | Waiting for its first attempt or its next retry, at next_attempt_at. |
sending | Being sent right now. |
delivered | Your endpoint answered 2xx, at delivered_at. |
failed | No more attempts will be made. |
response_status is the HTTP status your endpoint returned on the latest attempt. error describes a failure that had no HTTP answer, such as a timeout. An error of event_expired means the event passed the change feed’s 30-day limit before it could be delivered. Any failed delivery younger than that is still on the change feed: the push failed, but the event was not lost.
Hardening the receiver
- Verify before you parse. Treat the body as untrusted bytes until the signature checks out.
- Re-read, do not trust the payload as data. The payload is thin. Read the resource by handle, so what you store matches your scopes today and a replayed old delivery cannot bring back data that has since been erased.
- Expect bursts. A bulk change in your customer’s workspace produces many events at once. Queue them, and keep your answer fast.
- Keep the change feed as your backstop. The ordered feed is the source of truth. A reader that can only be pushed to has no way to recover from a gap.