Skip to content

Webhooks

Receive a signed HTTP POST when people sign in, accounts change or an administrator edits a realm, and verify that it came from Authio.

A webhook is a POST that Authio sends to an address of yours when something happens, so your systems can react without polling the API: provision a user in your database when the account is created, open a ticket when a refresh token is reused, mirror every administrative change into a compliance store.

Every delivery carries the same JSON envelope and an HMAC-SHA256 signature made with a secret only you and Authio know. Failed deliveries are retried on a schedule you choose, and every attempt is kept in a delivery history you can read, filter and replay from the console.

Client and realm endpoints

An endpoint is registered in one of two places, and that decides what it can hear:

Registered onWhat it hearsWhere in the console
A clientWhat happens to the people who use that application: sign-ins, accounts, tokens, keys and suspensions.The client’s Activity tab, or Webhooks with the client chosen
A realmEvery change an administrator makes anywhere in the realm: admin_resource_created, admin_resource_updated and admin_resource_deleted.The realm’s Activity tab, under Administrative trail

The two sets do not mix: a client endpoint cannot subscribe to the administrative events and a realm endpoint can only subscribe to them. An endpoint that names an event it would never receive is refused when you register it, rather than staying silent.

Add an endpoint

  1. Open the client’s webhooks

    In Clients, open the client and go to its Activity tab, or open Webhooks and choose the client. Registering an endpoint needs the client:write permission.

  2. Describe the endpoint

    Choose Add endpoint and fill in a name (up to 128 characters), the URL (an absolute https:// address with a host, up to 2,048 characters), an optional description and at least one event. Retries and First retry after set how failed deliveries are sent again; the defaults suit most receivers.

  3. Store the signing secret

    Authio generates the endpoint’s signing secret, a value that starts with whsec_. Put it in your receiver’s configuration, for example as AUTHIO_WEBHOOK_SECRET.

    The secret is shown once, right after the endpoint is created, and only to people with the secret:read permission. Afterwards the console shows only its last four characters; if you lose it, choose Rotate secret.
  4. Send a test

    Choose Send test. Authio posts a ping event to the endpoint and records the attempt in its history, with the status code and body your receiver answered with.

Event types

EventEndpointSent when
user_createdClientA user is created by any path: sign-up, an administrator, an import, or a first sign-in through an external identity provider.
user_updatedClientA user’s profile or status changes.
user_deletedClientAn administrator deletes a user. data is the account as it stood when it was deleted.
login_succeededClientAn authentication succeeds, in any grant.
login_failedClientAn authentication is refused.
user_locked_outClientAn account is locked after repeated failed attempts.
token_revokedClientA token is revoked explicitly.
keys_rotatedClientThe client’s signing and encryption keys rotate, by an administrator or on schedule.
token_reuse_detectedClientA refresh token is presented again after it was already exchanged. The session behind it has been ended.
user_suspendedClientA suspension is written, whether it starts now or later.
user_suspension_endedClientA suspension is lifted or runs out.
admin_resource_createdRealmAn administrator creates a resource: a client, a role, a key, anything.
admin_resource_updatedRealmAn administrator changes a resource.
admin_resource_deletedRealmAn administrator deletes a resource.
pingBothYou choose Send test. It cannot be subscribed to.

Only administrative changes that succeeded are announced; refused attempts stay in the audit trail. Token exchanges for connected accounts are recorded in the access trail but do not raise login_succeeded or login_failed, because they are not sign-ins.

Payload

Every event arrives in the same envelope. id identifies the event and stays the same across every retry and replay of it; created_at is in Unix seconds; realm_id and client_id name where it happened; data depends on the event.

login_succeededJSON
{
  "id": "6c1e0f7a-3b9d-4f2e-9a51-0d8c2b7e4f13",
  "type": "login_succeeded",
  "created_at": 1791360000,
  "realm_id": "acme",
  "client_id": "acme-web",
  "data": {
    "grant_type": "AuthorizationCode",
    "user_id": "0f6b2c4e-8a1d-4c3b-9e7f-5a2d1c8b6e40",
    "username": "ana",
    "email": "ana@acme.com",
    "ip_address": "203.0.113.24",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
    "session_id": "b7d4e2a1-5c3f-4e8b-a6d9-1f0c7e3b2a58",
    "failure_reason": null,
    "resource": null,
    "scopes": "openid profile email",
    "authentication_methods": ["pwd", "otp", "mfa"],
    "acr": "2",
    "identity_provider": null,
    "consumer_id": null,
    "consumer_name": null
  }
}

What each event carries in data

EventsFields in data
user_created, user_updated, user_deletedid, username, email, first_name, last_name, email_confirmed, status, realm_id, client_id
login_succeeded, login_failedgrant_type, user_id, username, email, ip_address, user_agent, session_id, failure_reason, resource, scopes, authentication_methods, acr, identity_provider, consumer_id, consumer_name
user_locked_outuser_id, username, email, lockout_end, access_failed_count
token_revokedtoken_id, subject, session_id, token_type_hint
token_reuse_detectedtoken_id, subject, session_id, ip_address, user_agent
keys_rotatedsigning_key_id, encryption_key_id, plus revoked_keys_count for a rotation by hand or rotation_days and automatic for a scheduled one
user_suspended, user_suspension_endedid, user_id, level, status, starts_at, ends_at, restricted_scopes, revokes_every_role, revoked_role_ids, sessions_terminated_at, lifted_at
admin_resource_created, admin_resource_updated, admin_resource_deletedaction, resource_type, resource_id, parent_id, realm_id, client_id, actor_type, actor_user_id, actor_username, actor_email, actor_credential_id, actor_credential_name, ip_address, method, path, plus details for a client secret rotation
pingmessage, subscription

resource tells a sign-in to your application apart from a token issued for a resource server such as an MCP server, and authentication_methods, acr and identity_provider tell how the person signed in. In the administrative events the kind of resource travels in resource_type, so the three event names never change when the API grows.

  • resource_id is what was created, changed or deleted, and parent_id what it belongs to, when the endpoint names it (the person a suspension is of, the identity provider of a role mapping); both are null when there is nothing to name, as for an import that touches many records at once.
  • actor_type says who made the change: User, a person signed in, or Credential, an account credential (an API key, a personal access token or a client secret). For a credential, actor_credential_id and actor_credential_name name it, actor_user_id and actor_username name the person it acts for, and actor_email is null.
  • A rotation of a client’s secret (admin_resource_updated) also carries details: mode — Immediate (the previous secret was refused at once), Overlap (it still works for a while) or Compromise (it was refused and the tokens issued with it revoked) — with previous_value_valid_until and issued_tokens_revoked_before, in UTC, for the modes they belong to. See Rotating secrets.
  • Calls that change nothing publish nothing: a test sent to a webhook, a replayed delivery, a token projection or claim-mapper preview, a confirmation e-mail resent, and every rehearsal (dryRun) of an import. A refused change, an impersonation and a change outside a realm publish nothing either.

Headers

HeaderValue
X-Authio-Signaturet={timestamp},v1={signature}: the Unix time the attempt was sent and the lowercase hex HMAC-SHA256 of {timestamp}.{body}.
X-Authio-EventThe event name, or ping, so you can route without parsing the body.
X-Authio-Event-IdThe event’s id, the same on every retry and replay.
Content-Typeapplication/json

Verify signatures

Anyone who knows your endpoint’s address can post to it, so check every delivery before acting on it:

  1. Read the raw request body, byte for byte, before any JSON parsing.
  2. Split X-Authio-Signature on commas and take the t and v1 values.
  3. Compute the HMAC-SHA256 of {t}.{raw body}, keyed with the whole secret including its whsec_ prefix, and encode it as lowercase hex.
  4. Compare it with v1 in constant time.
  5. Reject the delivery if t is more than five minutes away from your clock. The timestamp is set when each attempt is sent, so retries and replays carry a fresh one.
lib/authio-webhook.tsTypeScript
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

export function verifyAuthioSignature(
  payload: string,
  header: string | null,
  secret: string,
): boolean {
  if (!header) return false

  let timestamp = ''
  let signature = ''

  for (const part of header.split(',')) {
    const [name, value = ''] = part.trim().split('=', 2)
    if (name === 't') timestamp = value
    if (name === 'v1') signature = value
  }

  if (!/^\d+$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) {
    return false
  }

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
  if (age > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${payload}`)
    .digest()

  return timingSafeEqual(expected, Buffer.from(signature, 'hex'))
}

Then read the body as text in your handler, verify it and answer quickly:

app/api/webhooks/authio/route.tsTypeScript
import { verifyAuthioSignature } from '@/lib/authio-webhook'

interface AuthioEvent {
  id: string
  type: string
  created_at: number
  realm_id: string | null
  client_id: string | null
  data: Record<string, unknown>
}

export async function POST(request: Request) {
  const payload = await request.text()
  const valid = verifyAuthioSignature(
    payload,
    request.headers.get('x-authio-signature'),
    process.env.AUTHIO_WEBHOOK_SECRET!,
  )

  if (!valid) {
    return new Response('Invalid signature', { status: 400 })
  }

  const event = JSON.parse(payload) as AuthioEvent

  console.log(`Received ${event.type} (${event.id})`)

  return new Response(null, { status: 204 })
}
To check your setup without writing code, open Webhooks and choose Verify signature: paste a delivery’s raw body, the secret and the X-Authio-Signature header, and the console recomputes the signature in your browser. Nothing you paste there is sent anywhere.

Retries and delivery history

  • An attempt succeeds when your endpoint answers with any 2xx status within 10 seconds. Any other status, a timeout or a connection error is a failure. Redirects are not followed.
  • A failed event is retried on the endpoint’s policy. With the defaults (5 retries, first one after 60 seconds) the retries come about 1 minute, 5 minutes, 25 minutes, 2 hours and 6 hours after each failure: each waits five times longer than the one before, never more than six hours, moved by up to 20% either way.
  • Retries accepts 0 to 10 (0 turns retries off) and First retry after 30 to 3,600 seconds. Tests and manual resends are never retried.
  • After 10 consecutive failures the endpoint is disabled automatically; for a client endpoint, the account owner also gets a critical notification. A success resets the count, and automatic retries do not add to it. Retries already scheduled still go out while an endpoint is paused or disabled.

An endpoint is Active, Paused or Disabled. To bring a disabled realm endpoint back, fix the receiver and turn Receiving back on in its editor. The console registers, tests, rotates and deletes client endpoints; to change one, or set a disabled one back to Active, update it through the Admin API with PUT /api/v1/admin/realms/{realm}/clients/{client}/webhooks/{webhookId}.

The history of a client’s endpoints lives on Webhooks and in the client’s Activity tab. Overview shows attempts, acceptance rate, latency and the most common failure responses; Events lists each occurrence with how far its fan-out got (Pending, Delivered, Partial or Failed); Deliveries lists every attempt with the request body and your response. Each attempt is labelled with its kind:

KindWhat it is
EventThe first delivery of an event.
RetryAn automatic retry of a failed event.
ReplayA delivery someone sent again by hand.
TestA ping sent with Send test.

Replay this delivery sends the stored payload again, unchanged, even if the user or resource it describes has changed since. It only works while the endpoint is active, and it cancels any automatic retries still scheduled for the same event. For realm endpoints the console lists deliveries and sends tests; replay and metrics are available for client endpoints. Delivery history is kept for 30 days.

Test your endpoint

  • Send test posts a signed ping to one endpoint. It counts towards the endpoint’s health, because an endpoint that rejects a test has a real problem.
  • Capture endpoint, on Webhooks, opens a temporary address that records every request it receives — method, headers and body — for 24 hours. Use it as an endpoint’s URL to see exactly what Authio sends before your receiver exists. The address is protected only by its random path, so send it test data only.
  • Authio delivers only to public addresses, checked after DNS resolution: localhost and private networks are refused. To receive on your machine, use the capture endpoint or a tunnel that gives you a public HTTPS address.

Best practices

  • Verify before you act. Reject any delivery whose signature does not match or whose timestamp is outside your tolerance.
  • Answer fast, work later. Return a 2xx as soon as the delivery is verified and stored, and do the slow work in a queue. Ten seconds is the hard limit, and a slow endpoint fails deliveries it actually received.
  • Make handlers idempotent. Delivery is at least once: the same event can reach you again through a retry or a replay. Keep the id (also in X-Authio-Event-Id) of what you processed and skip repeats.
  • Do not rely on order. A retry can arrive hours after a newer event. Use created_at, or read the current state from the API, when order matters.
  • Treat `token_reuse_detected` as an incident. It means a refresh token was very likely stolen: someone other than its holder presented it.
  • Plan secret rotations. Rotate secret invalidates the old secret at once, with no overlap, so update your receiver right away. Deliveries that fail in between are retried on the endpoint’s policy, signed with the new secret.

Notification webhooks

Separate from event webhooks, your account’s operational notifications — expiring secrets, unusual sign-in failures, a disabled webhook, quotas near their limit — can also be posted to a URL. The account owner sets it in Notifications › Preferences. The body carries id, type, severity, title, description, resource_id, realm_id and created_at. These posts are not signed and do not appear in the delivery history, so treat them as alerts, not as a source of truth.

Next steps