Skip to main content

Webhooks

Tessera notifies your systems about platform events — completed syncs, finished training runs, activated deployment slots, quota and billing changes — by POSTing signed JSON envelopes to HTTPS endpoints you register per project.

Manage endpoints with the /webhook-endpoints API: register up to 16 active endpoints per project, each subscribed to specific event types or "*" (every type except the by-name-only types below). Or manage them in the console under Settings → Webhooks — endpoint creation with the one-time signing secret, secret rotation, re-enable after auto-disable, and the per-endpoint delivery log with plain-language failure guidance all live there.

An endpoint subscribed to entity-level events (one entity's output of a slot, such as score.updated or marker.fired) acts for the credential that subscribed it, so under purpose restrictions that credential needs the purposes of the project's slots, and each event is re-checked against its current allow-list before it is delivered. A refused endpoint is reported once as a webhook_endpoint.subscriber_refused audit event; updating the endpoint with its url or event_types (unchanged is enough) re-binds it to your credential. An API key holds one environment, so an endpoint that a key subscribed receives only that environment's entity-level events, slot outputs (including audience.synced, audience.failed, export.completed and export.failed), and the events whose data names an environment: audit.recorded, gate.failed (its environment is the model's), slot.activated, slot.rolled_back and refresh.* (plus those that name no environment, such as project-level audit records, except a gate.failed that names none, which reaches no API key's endpoint); one that a console session subscribed receives both environments'.

The envelope​

Every delivery body is one envelope, schema https://spec.tessera.dev/schemas/webhook_envelope.json.

Schema ids are not download locations

The $ids under https://spec.tessera.dev/schemas/ name our JSON Schemas; they are identifiers, not addresses. Nothing is served at those URLs, and a validator must not fetch them: resolve each $ref from your local copy of the schemas instead. The API reference's Schemas page renders every request and response schema of the API. The webhook payload schemas (this envelope and each event's data) are not published on this site yet: ask your Tessera contact for the schema files.

An envelope looks like this:

{
"id": "whe_01hv4d8p6q3k9m2x7c5t1n8r4z",
"type": "run.completed",
"created_at": "2026-01-01T00:00:00Z",
"project_id": "proj_01hv4czq7r9t2m5k8n3p6w1x4y",
"data": { "run_id": "run_01hv4d0s9t2v5x8z1c4f7j0m3q", "status": "succeeded" }
}

Event types (closed set): sync.completed, run.completed, gate.failed, bundle.created, slot.activated, slot.rolled_back, score.updated, export.completed, export.failed, refresh.completed, refresh.failed, drift.alert, quota.warning, quota.exceeded, billing.payment_failed, billing.state_changed, segment.migration, entity.went_quiet, marker.fired, alert.raised, alert.resolved, score.band_changed, audit.recorded, destination.delivery.completed, audience.synced, audience.failed, and the by-name-only types below. The data payload shape is owned by the producing feature and documented with it.

By-name-only event types​

These types are delivered only to endpoints that list them by name in event_types. The "*" wildcard does not include them, so an endpoint subscribed to "*" keeps receiving exactly the types it was written for.

TypeWhendata
warehouse_export.completeda state-snapshot export (requested or scheduled) succeededthe warehouse export record of GET /slots/{slot}/warehouse-exports/{export_id}, including mode and the schedule tick; output is summarized as the manifest URI, file_count and table (read the full file list from the GET)
warehouse_export.faileda state-snapshot export failedthe same record with a typed error
signals.updateda live slot wrote an entity's behavioral signals{slot, entity, signals, freshness}
drift.surprise_alertserved-surprise drift entered damped alertthe surprise drift point plus the thresholds in force

Warehouse exports send warehouse_export.* for every state-snapshot export, in addition to export.completed/export.failed, whose data is unchanged. The signals and surprise-drift types start with their producing feature's release.

These types carry slot output, and so do audience.synced (its artifact.url is a signed link to the audience's member file), audience.failed, export.completed (its output holds signed links to the exported files) and export.failed, which "*" does include. So like score.updated they reach an endpoint only while the credential that subscribed it may use the slot's purpose; an endpoint subscribing to any of them by name is checked the same way at creation. signals.updated is about one entity: once that entity is erased, its pending and future signals.updated deliveries are never sent.

Delivery semantics​

  • At-least-once, unordered. The same envelope can arrive more than once and envelopes are not ordered relative to each other — dedup on id.
  • Respond fast with a 2xx. A delivery succeeds only when a 2xx response arrives within 10 seconds. Redirects are never followed; a 3xx counts as a failure. Do your processing asynchronously and return 200 immediately.
  • Retries. After a failed attempt, Tessera retries at +1 m, +5 m, +30 m, +2 h and +21 h after the previous attempt (6 attempts total over ~24 h), then marks the delivery failed.
  • Auto-disable. An endpoint that keeps failing (20 consecutive failed attempts by default; any 2xx resets the counter) is disabled automatically, its pending deliveries are abandoned, and your organization's owners and admins are emailed. Re-enable with POST /webhook-endpoints/{endpoint_id}:enable once your receiver is healthy — envelopes emitted while disabled are not replayed.
  • Endpoints must be public HTTPS. Plain HTTP is rejected, and hosts must resolve to public unicast addresses (private, loopback, link-local and metadata ranges are refused, at registration and again before every attempt).
  • Delivery log. GET /webhook-endpoints/{endpoint_id}/deliveries lists each delivery's attempts, response codes and typed errors, newest first, for 30 days.

Samples and replays​

Both operations need an owner or admin (or an admin-scoped key), go only to the endpoint's registered URL, and are signed and retried like any other delivery. The console's endpoint drawer offers them as Send sample and Replay.

  • Send a sample. POST /webhook-endpoints/{endpoint_id}:send-sample with {"event_type": "<subscribed type>", "expected_url": "<current endpoint URL>"} queues one envelope whose data is a fixed test marker for that endpoint only. That data holds no slot, entity or score payload, so a receiver can tell a test send from a real event by its shape; it is signed like every real delivery and is made only by this operation. It appears in the delivery log immediately. A 202 means the sample was queued; it does not prove that your receiver verified the signature or acted on the event. Each sample is recorded in the audit trail as webhook_endpoint.sample.
  • Replay a finished delivery. POST /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}:replay with {"reason": "<why>", "expected_url": "<current endpoint URL>"} re-sends a delivery that succeeded, failed or was abandoned. The replay is a separate run with up to six attempts of its own; the original delivery and its attempts never change. It keeps the original envelope id and bytes, so a receiver that deduplicates on id applies it at most once. Only one replay of a delivery can be active, and a delivery whose payload is gone, or that is too close to the end of the 30-day retention window for a full retry schedule, cannot be replayed. The request is recorded as webhook_delivery.replay with your reason, and the end of the run as webhook_delivery.replay_completed.
  • Replay history. GET /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/replays lists a delivery's replay runs, newest first.
  • Destination confirmation. If expected_url no longer matches the endpoint, both operations fail with failed_precondition (endpoint_destination_changed). Queued sample or replay work is abandoned without a send if the URL changes before an attempt.

Recognising a test send​

Every delivery carries a Tessera-Delivery-Kind header that says what made it:

Tessera-Delivery-Kind: test
  • test: the delivery was queued by POST /webhook-endpoints/{endpoint_id}:send-sample (Send sample in the console). Every retry of it, and every replay of it, also says test.
  • event: every other delivery, that is a real platform event, its retries and its replays.

To keep test sends out of your business logic, verify the signature, answer 200, and stop when the header is exactly test; handle every other value, or a missing header, as a real delivery. Tessera sets the header from the operation that queued the delivery; endpoint settings cannot set or change it. The envelope is the same for both kinds. The header is not covered by Tessera-Signature (the signature covers only t and the body), so verification works the same whether or not your receiver reads it; rely on the header only for a delivery whose signature you have verified.

Verifying signatures​

Every request carries the header:

Tessera-Signature: t=<unix seconds>,v1=<hex>

where hex = HMAC-SHA256(secret, "{t}." + body_bytes) and secret is the whsec_… value shown exactly once when you created (or rotated) the endpoint. Always verify before trusting a payload, and reject timestamps older than 300 seconds (replay protection).

The delivery kind header is not signed

Deliveries also carry Tessera-Delivery-Kind (test or event, see Recognising a test send). It is not part of the signed message, so a receiver verifies the same way whether or not it reads that header.

Inside the Tessera monorepo, import the single implementation:

from tessera_core.webhooks.signature import verify

def handle(request_body: bytes, signature_header: str, now_unix: int) -> bool:
return verify(WEBHOOK_SECRET, signature_header, request_body,
now=now_unix, tolerance_seconds=300)

Standalone receivers can implement the same check, retaining every rotation signature and rejecting malformed timestamps:

import hashlib
import hmac
import time

def verify_tessera_signature(secret: str, header: str, body: bytes,
tolerance_seconds: int = 300) -> bool:
t = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t":
if t is not None:
return False
try:
t = int(value)
except ValueError:
return False
elif key == "v1":
if not value or not value.isascii():
return False
signatures.append(value)
# Ignore unknown scheme keys for forward compatibility.
if t is None or not signatures:
return False
if abs(int(time.time()) - t) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body,
hashlib.sha256).hexdigest()
matched = False
for candidate in signatures:
# Compare every candidate without an early successful return.
if hmac.compare_digest(candidate, expected):
matched = True
return matched

Compute the HMAC over the raw request bytes — do not re-serialize the JSON. Use a constant-time comparison.

Secret rotation​

POST /webhook-endpoints/{endpoint_id}:rotate-secret returns a new secret (shown once). For 24 hours after rotation, every signature header carries two signatures — t=<ts>,v1=<new>,v1=<old> — so you can roll your stored secret over without dropping deliveries: accept the header if any v1 matches. After the grace window the old secret is destroyed and headers carry a single v1 again.