Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

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 403 and 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.

RuleWhy
Must use httpsThe payload is signed, not encrypted. Over plain http it travels in clear text.
Must use port 443The default https port only.
Must be a hostname, not an IP addressAn IP literal has no TLS name to verify and cannot follow your own DNS changes.
Must be a fully-qualified public hostnamehooks.example.com, not internal-host.
Must not contain credentialsNo 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 typeFires when
user.signed_upA new user registered in your organization.
user.login_throttledRepeated failed sign-ins for one of your users were rate-limited — a possible credential-stuffing attempt.
user.sessions_revokedAll sessions for a user were revoked (sign-out everywhere).
user.email_verifiedA user confirmed their email address.
apikey.issuedAn API key was created.
apikey.revokedAn API key was revoked; integrations using it will get 401 on their next request.
apikey.rotatedAn API key was rotated; the previous key works until revoked.
apikey.quota_exceededYour monthly API quota is spent — integrations are getting 429. Sign-ins are unaffected.
oauth.client_createdAn OAuth application was registered.
oauth.client_deletedAn OAuth application was removed.
content.publishedA 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

HeaderContents
Zynth-Signaturet=<unix-seconds>,v1=<hex-hmac-sha256> — see §3
Zynth-Event-IdSame value as id in the body
Zynth-Event-TypeSame value as type in the body
Zynth-Delivery-Attempt1 on the first attempt, then 2, 3… on retries
User-AgentZynth-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:

  1. 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.
  2. 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.
  3. 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

AttemptRoughly when
1Immediately
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:

  1. Rotate.
  2. Deploy your handler accepting both the old and new secret (the *secrets argument above).
  3. 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 logGET /api/v1/tenants/webhooks/{id}/deliveries?limit=50, showing status, attempts, last_status_code, last_error and next_attempt_at per delivery.

Replay onePOST /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.

MethodPathPurpose
GET/eventsThe 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-secretRotate, keeping the previous secret live
DELETE/{id}Remove an endpoint
POST/{id}/testSend a test event now
GET/{id}/deliveriesThe delivery log
POST/{id}/deliveries/{delivery_id}/replayRe-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:

  1. Is the endpoint enabled? Check enabled and disabled_reason — 20 consecutive failures auto-disables it.
  2. Are you subscribed to that type? An endpoint with an empty event_types gets nothing.
  3. Send a test event. It tells you in one step whether the destination is reachable.
  4. blocked_by_policy: true? Your hostname resolves to a private/internal address. It must be publicly resolvable.
  5. Check last_error in the delivery log: timeout (you did not answer in 10s), http_status with last_status_code (you answered 4xx/5xx), blocked (destination refused), or a connection/TLS error name (certificate or DNS problem on your side).
  6. 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.