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 on | What it hears | Where in the console |
|---|---|---|
| A client | What 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 realm | Every 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
Open the client’s webhooks
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.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 asAUTHIO_WEBHOOK_SECRET.The secret is shown once, right after the endpoint is created, and only to people with thesecret:readpermission. Afterwards the console shows only its last four characters; if you lose it, choose Rotate secret.Send a test
Choose Send test. Authio posts a
pingevent to the endpoint and records the attempt in its history, with the status code and body your receiver answered with.
Event types
| Event | Endpoint | Sent when |
|---|---|---|
user_created | Client | A user is created by any path: sign-up, an administrator, an import, or a first sign-in through an external identity provider. |
user_updated | Client | A user’s profile or status changes. |
user_deleted | Client | An administrator deletes a user. data is the account as it stood when it was deleted. |
login_succeeded | Client | An authentication succeeds, in any grant. |
login_failed | Client | An authentication is refused. |
user_locked_out | Client | An account is locked after repeated failed attempts. |
token_revoked | Client | A token is revoked explicitly. |
keys_rotated | Client | The client’s signing and encryption keys rotate, by an administrator or on schedule. |
token_reuse_detected | Client | A refresh token is presented again after it was already exchanged. The session behind it has been ended. |
user_suspended | Client | A suspension is written, whether it starts now or later. |
user_suspension_ended | Client | A suspension is lifted or runs out. |
admin_resource_created | Realm | An administrator creates a resource: a client, a role, a key, anything. |
admin_resource_updated | Realm | An administrator changes a resource. |
admin_resource_deleted | Realm | An administrator deletes a resource. |
ping | Both | You 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.
{
"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
| Events | Fields in data |
|---|---|
user_created, user_updated, user_deleted | id, username, email, first_name, last_name, email_confirmed, status, realm_id, client_id |
login_succeeded, login_failed | grant_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_out | user_id, username, email, lockout_end, access_failed_count |
token_revoked | token_id, subject, session_id, token_type_hint |
token_reuse_detected | token_id, subject, session_id, ip_address, user_agent |
keys_rotated | signing_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_ended | id, 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_deleted | action, 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 |
ping | message, 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_idis what was created, changed or deleted, andparent_idwhat it belongs to, when the endpoint names it (the person a suspension is of, the identity provider of a role mapping); both arenullwhen there is nothing to name, as for an import that touches many records at once.actor_typesays who made the change:User, a person signed in, orCredential, an account credential (an API key, a personal access token or a client secret). For a credential,actor_credential_idandactor_credential_namename it,actor_user_idandactor_usernamename the person it acts for, andactor_emailisnull.- A rotation of a client’s secret (
admin_resource_updated) also carriesdetails:mode—Immediate(the previous secret was refused at once),Overlap(it still works for a while) orCompromise(it was refused and the tokens issued with it revoked) — withprevious_value_valid_untilandissued_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
| Header | Value |
|---|---|
X-Authio-Signature | t={timestamp},v1={signature}: the Unix time the attempt was sent and the lowercase hex HMAC-SHA256 of {timestamp}.{body}. |
X-Authio-Event | The event name, or ping, so you can route without parsing the body. |
X-Authio-Event-Id | The event’s id, the same on every retry and replay. |
Content-Type | application/json |
Verify signatures
Anyone who knows your endpoint’s address can post to it, so check every delivery before acting on it:
- Read the raw request body, byte for byte, before any JSON parsing.
- Split
X-Authio-Signatureon commas and take thetandv1values. - Compute the HMAC-SHA256 of
{t}.{raw body}, keyed with the whole secret including itswhsec_prefix, and encode it as lowercase hex. - Compare it with
v1in constant time. - Reject the delivery if
tis 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.
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:
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 })
}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
2xxstatus 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:
| Kind | What it is |
|---|---|
Event | The first delivery of an event. |
Retry | An automatic retry of a failed event. |
Replay | A delivery someone sent again by hand. |
Test | A 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
pingto 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:
localhostand 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
2xxas 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 inX-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.