Skip to content

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

Request headers
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
HeaderWhat it holds
Tahoe-SignatureThe signature. Check it before anything else.
Tahoe-Event-IdThe event’s id. Use it to skip an event you already handled.
Tahoe-Delivery-IdThis delivery. It matches id in the delivery log.
Tahoe-Delivery-Attempt1 for the first try, then counts up on each retry.
Tahoe-Api-VersionThe API version the event was shaped by.
Request body
{
  "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

PropertyValue
HeaderTahoe-Signature
Formatt=<unix seconds>,v1=<hex>[,v1=<hex>]
AlgorithmHMAC-SHA256 with your signing secret, over the string {t}.{raw_body}
Tolerance300 seconds between t and your clock

Three rules, and each one matters:

  1. 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.
  2. 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.
  3. Accept either v1. After the signing secret is rotated, the header carries one v1 for the new secret and one for the previous secret. Accept the delivery if either matches, so a rotation never becomes an outage.
verify.py
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)
Receiver (Flask)
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 "", 204

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 301 or 302 is not followed. Register the final URL.
  • 410 Gone switches 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-After is honored. If your response carries Retry-After in 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.

AttemptSent
1Shortly after the event is recorded
210 seconds after attempt 1 fails
330 seconds later
42 minutes later
510 minutes later
61 hour later
76 hours later
812 hours later
924 hours later
1024 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.

Request
curl https://tahoe.workonward.com/api/partner/v1/webhooks/endpoints \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "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_types means 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, and signing_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_address and 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.

FieldTypeNotes
urlstring, requiredUp to 2,048 characters. See the rules above.
event_typesstring[], required1 to 40 event types.
descriptionstringOptional, at most 200 characters.
Request
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"
  }'
Response: 201
{
  "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_secret is whsec_ followed by 43 URL-safe characters. It appears once. The response carries Cache-Control: no-store and is never kept for replay, so an Idempotency-Key does not help here. A retry runs again and meets 409 webhook_endpoint_exists if the first call succeeded. In that case call rotate_secret to 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.

Request
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 url goes through the same checks as create and resets consecutive_failures. A new event_types list replaces the old one and is checked against your scopes.
  • "enabled": false is the same as disable. "enabled": true turns an endpoint back on, including one Tahoe disabled after repeated failures or a 410. It clears disabled_at and disabled_reason, resets consecutive_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.

Request
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"
Response (shortened)
{
  "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 v1 values 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".

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

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"
Response: 200
{
  "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 200 from your receiver means the whole path works. Read the outcome from GET /webhooks/deliveries?endpoint_id=.... delivery_id is the id of that delivery.
  • The body is the normal event envelope with type: "ping" and a data.object of {"endpoint_id": "..."}. It is kept for four days and appears in GET /events only for keys that hold webhooks:write in the same workspace.
  • One test per endpoint per 10 seconds. A repeat with the same Idempotency-Key returns the same delivery_id and queues nothing new.

Errors from these calls

StatusCodeWhat to do
400invalid_requestThe 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.
400invalid_event_typeA name is not in the event catalogue. param is event_types.
400event_type_not_permittedThe key does not hold the read scope of that event type. The message names the type and the scope.
400webhook_url_not_allowedThe URL breaks a rule above, does not resolve, or resolves to a non-public address. param is url.
403webhooks_self_serve_disabledRegistering endpoints through the API is not switched on yet. Email [email protected].
403insufficient_scopeThe key lacks webhooks:write.
403write_requires_api_keyThe credential is a Sign in with Tahoe token, or is not tied to a Tahoe user.
404not_foundNo such endpoint for this key and workspace.
409webhook_endpoint_existsThis key already has an endpoint with that URL in this workspace. Change or turn on that one.
409webhook_endpoint_limit_reachedOne of the three limits above. Disable an endpoint first.
409webhook_endpoint_disabledTest only. Turn the endpoint on first.
429webhook_test_throttledTest only. One test per endpoint per 10 seconds. Wait for Retry-After.
429webhook_secrets_unavailableTahoe cannot create or read signing secrets right now. Nothing was changed. Retry later.
429webhooks_delivery_disabledTest 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.

ParameterNotes
endpoint_idOnly deliveries to this endpoint.
event_idOnly deliveries of this event (evt_ handle).
statuspending, sending, delivered or failed.
limitDefault 25, maximum 100.
Request
curl "https://tahoe.workonward.com/api/partner/v1/webhooks/deliveries?status=pending&limit=50" \
  -H "Authorization: Bearer $TAHOE_API_KEY"
Response
{
  "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
}
statusMeaning
pendingWaiting for its first attempt or its next retry, at next_attempt_at.
sendingBeing sent right now.
deliveredYour endpoint answered 2xx, at delivered_at.
failedNo 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.