Webhooks
A webhook lets Zynth Auth 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 yet. 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 | Roughly when |
|---|---|
| 1 | Immediately |
| 2 | ~30s later |
| 3 | ~1m later |
| 4 | ~2m later |
| … | doubling each time, ±25% jitter |
| 8 (last) | capped at 6h between attempts |
After the 8th failed attempt the delivery moves to dead_letter. It is not discarded — it
stays visible in the delivery log and you can replay it.
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.
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.
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.