Webhooks
A webhook lets Perihelium tell your systems when something happens — a user was locked out by rate limiting, an API key was revoked, an OAuth application was registered — without you polling for it.
We POST a small JSON document to an HTTPS URL you control. Every request is signed, so you can prove it came from us and has not been altered in transit.
Before you start
You need the tenant:manage permission (organization admin). Webhooks are configured per
organization, and every endpoint receives only your organization's own events.
If registration is refused with
403and a message about finishing setup, the organization is quarantined — it was opened by an AI agent and its owner has not approved the setup. See Agent-driven onboarding.
1. Register an endpoint
In the console: Settings → Webhooks → Add endpoint. Or via the API:
curl -X POST https://auth.zynthmedia.com/api/v1/tenants/webhooks \
-H "Authorization: Bearer $ZYNTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/zynth",
"description": "Production event sink",
"event_types": ["user.login_throttled", "apikey.revoked"]
}'
The response contains your signing secret, and it is the only time we will ever show it:
{
"id": "3f1b...",
"url": "https://hooks.example.com/zynth",
"event_types": ["apikey.revoked", "user.login_throttled"],
"enabled": true,
"secret": "whsec_A1b2C3..."
}
Store it the way you store a database password. If you lose it, rotate rather than re-register — see Rotating the signing secret.
URL requirements
Your URL is checked when you register it, and a rejection tells you exactly which rule it broke.
| Rule | Why |
|---|---|
Must use https | The payload is signed, not encrypted. Over plain http it travels in clear text. |
| Must use port 443 | The default https port only. |
| Must be a hostname, not an IP address | An IP literal has no TLS name to verify and cannot follow your own DNS changes. |
| Must be a fully-qualified public hostname | hooks.example.com, not internal-host. |
| Must not contain credentials | No https://user:pass@…. Authenticate the request with the signature instead. |
Requests are also refused at delivery time if the hostname resolves to a private or internal address. This is deliberate and cannot be waived — it is the boundary that stops a webhook URL from being used to reach systems that are not yours.
An endpoint with an empty event_types receives nothing. Subscribe explicitly.
2. The event catalogue
GET /api/v1/tenants/webhooks/events returns the live list. As of schema version 1:
| Event type | Fires when |
|---|---|
user.signed_up | A new user registered in your organization. |
user.login_throttled | Repeated failed sign-ins for one of your users were rate-limited — a possible credential-stuffing attempt. |
user.sessions_revoked | All sessions for a user were revoked (sign-out everywhere). |
user.email_verified | A user confirmed their email address. |
apikey.issued | An API key was created. |
apikey.revoked | An API key was revoked; integrations using it will get 401 on their next request. |
apikey.rotated | An API key was rotated; the previous key works until revoked. |
apikey.quota_exceeded | Your monthly API quota is spent — integrations are getting 429. Sign-ins are unaffected. |
oauth.client_created | An OAuth application was registered. |
oauth.client_deleted | An OAuth application was removed. |
content.published | A page on your site went live or its live version changed — a publish, a scheduled publish firing, or a rollback. The payload carries the slug and path; fetch the path to get the new content. |
Subscribing to an unknown type is rejected with a 400 rather than silently ignored — otherwise you would believe you were subscribed to something that will never arrive.
Payload shape
Every payload has the same four top-level fields:
{
"id": "9f2c1ea84c0b4d2f9a7e5c31b8d40f62",
"type": "user.login_throttled",
"schema_version": 1,
"data": {
"user_id": "c81d4e2e-bcf2-11e6-869b-7df92533d2db",
"actor_type": "human",
"occurred_at": "2026-08-01T09:21:44.318000+00:00"
}
}
id— unique per event. This is your idempotency key (see §4).type— the catalogue name above. Stable forever once published.schema_version— bumped only if a field changes meaning or is removed. Adding a field does not bump it, so parse tolerantly and ignore fields you do not recognise.data— fields specific to that event type.
Request headers
| Header | Contents |
|---|---|
Zynth-Signature | t=<unix-seconds>,v1=<hex-hmac-sha256> — see §3 |
Zynth-Event-Id | Same value as id in the body |
Zynth-Event-Type | Same value as type in the body |
Zynth-Delivery-Attempt | 1 on the first attempt, then 2, 3… on retries |
User-Agent | Zynth-Auth-Webhooks/1 |
Zynth-Delivery-Attempt greater than 1 means we are retrying. It is not a sign of a problem on
your side — it is expected behaviour, stated in the header so you do not have to guess.
3. Verify the signature
Do this before you trust the payload. Anyone can POST JSON at your URL; only we can sign it.
The header looks like this:
Zynth-Signature: t=1785936104,v1=5257a869e7ecebeda32affa62cdca3fa793333bc2e6c3a4e1a9dd6f9c9d2b5a1
The signed material is the timestamp, a literal ., and the exact raw request body:
HMAC-SHA256( secret, "<t>.<raw body bytes>" )
Three rules that matter:
- Sign the raw bytes. Do not parse the JSON and re-serialise it — key order and whitespace would change and the signature would never match. Capture the body before any JSON middleware.
- Check the timestamp and reject anything outside ±5 minutes. The timestamp is inside the signed material, so it cannot be edited without breaking the signature. Without this check a captured request stays replayable forever.
- Compare in constant time. A byte-by-byte
==in your handler is a timing oracle against your own secret.
The header may carry more than one v1= value during a secret rotation. Accept the request
if any of them matches — that is what makes rotation non-breaking.
Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify(header: str, body: bytes, *secrets: str, tolerance: int = TOLERANCE_SECONDS) -> bool:
"""Verify a Zynth-Signature header. `body` must be the RAW request bytes."""
if not header or not secrets:
return False
fields: dict[str, list[str]] = {}
for chunk in header.split(","):
key, _, value = chunk.strip().partition("=")
if key and value:
fields.setdefault(key, []).append(value)
stamps = fields.get("t", [])
signatures = fields.get("v1", [])
if len(stamps) != 1 or not signatures:
return False
try:
ts = int(stamps[0])
except ValueError:
return False
# Reject both directions: a far-future timestamp is as suspicious as a stale one.
if abs(int(time.time()) - ts) > tolerance:
return False
expected = [
hmac.new(s.encode(), b"%d." % ts + body, hashlib.sha256).hexdigest() for s in secrets
]
# Do not short-circuit across candidates — that would leak which secret matched.
return any(
hmac.compare_digest(candidate, valid) for valid in expected for candidate in signatures
)
With FastAPI or Flask, take the raw body with await request.body() / request.get_data().
Node.js
const crypto = require("node:crypto");
const TOLERANCE_SECONDS = 300;
function verify(header, body, secrets, tolerance = TOLERANCE_SECONDS) {
if (!header || secrets.length === 0) return false;
const fields = {};
for (const chunk of header.split(",")) {
const idx = chunk.indexOf("=");
if (idx <= 0) continue;
const key = chunk.slice(0, idx).trim();
(fields[key] ||= []).push(chunk.slice(idx + 1));
}
const stamps = fields.t || [];
const signatures = fields.v1 || [];
if (stamps.length !== 1 || signatures.length === 0) return false;
const ts = Number.parseInt(stamps[0], 10);
if (!Number.isInteger(ts)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - ts) > tolerance) return false;
const signed = Buffer.concat([Buffer.from(`${ts}.`), body]);
const expected = secrets.map((s) =>
crypto.createHmac("sha256", s).update(signed).digest("hex"),
);
let matched = false;
for (const valid of expected) {
for (const candidate of signatures) {
const a = Buffer.from(candidate, "utf8");
const b = Buffer.from(valid, "utf8");
// timingSafeEqual throws on a length mismatch, so check length first.
if (a.length === b.length && crypto.timingSafeEqual(a, b)) matched = true;
}
}
return matched;
}
In Express, express.json() discards the raw body. Use
express.raw({ type: "application/json" }) on the webhook route, or capture it with the
verify callback, and only then JSON.parse it.
4. The delivery contract
Read this section before you write your handler. It is short, and every line of it changes how correct code looks.
Delivery is at-least-once. A network timeout can happen after your server processed the request but before we saw your response, so we retry and you get the same event twice. This is inherent to webhooks — anyone claiming exactly-once is describing something else.
Use id as an idempotency key. Record the ids you have processed and skip repeats. A replay
of a failed delivery deliberately carries the same id as the original, so your dedupe keeps
working across replays too.
Delivery is unordered. Retries and backoff mean a later event can arrive before an earlier
one. Never infer sequence from arrival order — use data.occurred_at, which is why it is there.
Respond quickly. Any 2xx or 3xx counts as success. We time out after 10 seconds, and a
slow handler burns retries. Acknowledge first, process asynchronously.
Redirects are not followed. A 301/302 counts as success only in that it is under 400 — we
will not chase it. Register the final URL.
Your response body is discarded. We read a bounded prefix and throw it away. Do not put diagnostics there; nobody will read them.
5. Retries, failures and dead letters
| Attempt | After the previous attempt | Since the first attempt |
|---|---|---|
| 1 | — | immediately |
| 2 | ~30s | ~30s |
| 3 | ~1m | ~1.5m |
| 4 | ~2m | ~3.5m |
| 5 | ~4m | ~7.5m |
| 6 | ~8m | ~15m |
| 7 | ~16m | ~30m |
| 8 | ~32m | ~1h |
| 9 | ~1h | ~2h |
| 10 | ~2h | ~4h |
| 11 | ~4h | ~8.5h |
| 12 | ~6h | ~14.5h |
| 13 | ~6h | ~20.5h |
| 14 | ~6h | ~26.5h |
| 15 (last) | ~6h | ~32h |
Every gap carries ±25% jitter, and no gap is longer than about six hours. The whole schedule
spans about a day and a half, and never less than 24 hours. An endpoint that is down overnight is
retried until it is back. If it is down for longer than that, expect the delivery to reach
dead_letter and plan to replay it.
After the 15th failed attempt the delivery moves to dead_letter. It is not discarded — it
stays visible in the delivery log and you can replay it from Settings → Webhooks (or the API).
The jitter is deliberate: without it, everything queued during your outage would retry in the same instant when you recover and knock you over again.
Auto-disable. After 20 consecutive failures across deliveries, the endpoint is disabled
and we stop sending. You will see disabled_reason on the endpoint. Fix your side, then re-enable
it — re-enabling clears the failure counter, so one failure afterwards will not disable it again.
The count is of failed delivery attempts, not of time, and it is across all of your endpoint's
deliveries. One event failing every attempt stays under it (15 attempts), so the schedule above runs
to its end. Two or more events failing together reach 20 first, around the tenth attempt of each, so
the endpoint is disabled partway through their schedules; and a burst of 20 or more events arriving
while your endpoint is unreachable can disable it within seconds. While an endpoint is disabled, new
events are not queued for it at all, so there is nothing from that window to replay — re-enabling
resumes delivery from that moment on. Deliveries already queued when it was disabled stay in the log
as dead_letter and can be replayed after you re-enable.
Sending a test delivery never counts toward that. A failing test send tells you what is wrong without moving you closer to having your live integration switched off.
How long the delivery log is kept
Delivery records are retained for 90 days from when the event was queued, then removed. Only
records in a final state age out — delivered and dead_letter. Anything still queued or waiting
on a retry is kept for as long as it takes, however old it is: those are events we still owe you,
and we do not expire our own backlog.
If you need delivery history beyond 90 days, capture it on your side from the events themselves —
the Zynth-Event-Id header is stable across retries and replays, so it is a reliable key to store
against.
6. Rotating the signing secret
curl -X POST https://auth.zynthmedia.com/api/v1/tenants/webhooks/$ENDPOINT_ID/rotate-secret \
-H "Authorization: Bearer $ZYNTH_TOKEN"
You get a new secret, and the previous one keeps working. During the overlap every delivery is signed with both, so:
- Rotate.
- Deploy your handler accepting both the old and new secret (the
*secretsargument above). - Remove the old secret from your config whenever you like.
The overlap ends when you rotate again — not on a timer. A time-based expiry would silently break an integrator who was slower than our clock.
Rotate immediately if a secret is exposed. Because the old secret stays live until the next rotation, rotating twice is how you cut off an exposed one straight away.
7. Testing, inspecting and replaying
Send a test event — synchronous, exercises the real signing and the real network path, so a green result means the integration works:
curl -X POST https://auth.zynthmedia.com/api/v1/tenants/webhooks/$ENDPOINT_ID/test \
-H "Authorization: Bearer $ZYNTH_TOKEN"
{ "delivered": false, "status_code": null, "error": "blocked", "blocked_by_policy": true }
blocked_by_policy: true means we refused the destination — your URL resolved to a private
or internal address. That is a different problem from your endpoint being down, and it is
reported separately so you do not go looking in the wrong place.
The test event has type zynth.test and is never written to your delivery log.
Inspect the log — GET /api/v1/tenants/webhooks/{id}/deliveries?limit=50, showing status,
attempts, last_status_code, last_error and next_attempt_at per delivery.
Replay one — POST /api/v1/tenants/webhooks/{id}/deliveries/{delivery_id}/replay. This
queues a new delivery carrying the same event_id; the original attempt history stays intact
as a record of what happened.
8. API summary
All paths are under /api/v1/tenants/webhooks and require tenant:manage.
| Method | Path | Purpose |
|---|---|---|
GET | /events | The published event catalogue |
GET | `` | List your endpoints |
POST | `` | Register an endpoint (returns the secret once) |
PATCH | /{id} | Update description, subscriptions, or enable/disable |
POST | /{id}/rotate-secret | Rotate, keeping the previous secret live |
DELETE | /{id} | Remove an endpoint |
POST | /{id}/test | Send a test event now |
GET | /{id}/deliveries | The delivery log |
POST | /{id}/deliveries/{delivery_id}/replay | Re-queue a delivery |
A maximum of 25 endpoints per organization.
What we will never send
Webhook payloads carry only the fields listed for each event type. They never include session identifiers, IP addresses, request correlation ids, our internal security telemetry, or anything about another organization. The published catalogue is a deliberately separate, closed list — an event is not publishable until it is explicitly added to it.
The one event that originates from a detection, alert.raised, is not in the catalogue and cannot be subscribed to here: it is sent only by an alert channel you create, and carries a fixed, minimal set of fields (never the detection's group, description or event data).
Notably, user.login_throttled does not include the source IP address. You can act on the
signal without it, and receiving it would hand you a data-protection obligation you did not ask
for.
Troubleshooting
Not arriving? Work down this list:
- Is the endpoint enabled? Check
enabledanddisabled_reason— 20 consecutive failures auto-disables it. - Are you subscribed to that type? An endpoint with an empty
event_typesgets nothing. - Send a test event. It tells you in one step whether the destination is reachable.
blocked_by_policy: true? Your hostname resolves to a private/internal address. It must be publicly resolvable.- Check
last_errorin the delivery log:timeout(you did not answer in 10s),http_statuswithlast_status_code(you answered 4xx/5xx),blocked(destination refused), or a connection/TLS error name (certificate or DNS problem on your side). - Signature failing? You are almost certainly verifying a re-serialised body instead of the raw bytes. That is the single most common cause.
Still stuck? Contact support with your endpoint id and the event_id of a delivery that failed.