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.
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.
| Type | When | data |
|---|---|---|
warehouse_export.completed | a state-snapshot export (requested or scheduled) succeeded | the 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.failed | a state-snapshot export failed | the same record with a typed error |
signals.updated | a live slot wrote an entity's behavioral signals | {slot, entity, signals, freshness} |
drift.surprise_alert | served-surprise drift entered damped alert | the 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
200immediately. - 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}:enableonce 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}/deliverieslists 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-samplewith{"event_type": "<subscribed type>", "expected_url": "<current endpoint URL>"}queues one envelope whosedatais a fixed test marker for that endpoint only. Thatdataholds 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. A202means 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 aswebhook_endpoint.sample. - Replay a finished delivery.
POST /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}:replaywith{"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 envelopeidand bytes, so a receiver that deduplicates onidapplies 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 aswebhook_delivery.replaywith your reason, and the end of the run aswebhook_delivery.replay_completed. - Replay history.
GET /webhook-endpoints/{endpoint_id}/deliveries/{delivery_id}/replayslists a delivery's replay runs, newest first. - Destination confirmation. If
expected_urlno longer matches the endpoint, both operations fail withfailed_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 byPOST /webhook-endpoints/{endpoint_id}:send-sample(Send sample in the console). Every retry of it, and every replay of it, also saystest.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).
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.