Webhooks
Outbound, HMAC-signed delivery of authentication events. Subscribers are notified in near-real-time when users register, log in, log out, enroll MFA, get removed from orgs, etc. — so you can mirror IdentSphere's auth events into your own audit log or trigger downstream automation without polling.
The engine lives in the identsphere-webhooks crate. It hooks into the audit
pipeline: every audit event that maps to a webhook event type is dispatched
as it's logged. Events that don't map (failed logins, list operations) are not
delivered — use the audit-log API for those.
Enabling
Webhooks are off by default. Turn them on with one env var — a
comma-separated list of url|secret pairs:
IDENTSPHERE_WEBHOOK_ENDPOINTS="https://app.example.com/hooks/identsphere|whsec_abc123"
# multiple subscribers fan out — each gets its own signed delivery:
IDENTSPHERE_WEBHOOK_ENDPOINTS="https://a.example.com/h|secretA,https://b.example.com/h|secretB"
Each endpoint's secret is the HMAC signing key for deliveries to that URL.
Rotate by changing the value and redeploying.
Delivery
Every delivery is a POST with these headers:
POST {your_url}
Content-Type: application/json
X-IdentSphere-Event-Id: 3f1c… (UUID, stable across any future retries)
X-IdentSphere-Event-Type: session_created
X-IdentSphere-Timestamp: 1748448000
X-IdentSphere-Signature: t=1748448000,v1=abc…hmac_sha256_hex
{
"id": "3f1c…",
"event": "session_created",
"timestamp": 1748448000,
"data": {
"organization_id": "…",
"actor_id": "…",
"actor_type": "user",
"action": "auth.login",
"resource_type": "session",
"resource_id": "…",
"ip_address": "203.0.113.7",
"user_agent": "Mozilla/5.0 …",
"request_id": "7f3a…",
"outcome": "success",
"metadata": { … }
}
}
ip_address, user_agent, and request_id carry the request context of the
event — use them to attach IP/geo + device to auth events (login, MFA, passkey)
on the receiving side. They are null for system-originated events that have no
request context.
Semantics
- Best-effort, fire-and-forget, non-blocking. Delivery never blocks or fails the originating auth request. Each endpoint is delivered on its own task; non-2xx responses and transport errors are logged, not retried (v1).
- No per-endpoint filtering — every subscriber receives every mapped event.
Filter on
eventin your handler. - Need at-least-once durability? Point a subscriber at a thin relay (a queue producer — SQS / Kafka / a Cloudflare Worker) and fan out from there, or treat the audit-log API as the durable source of truth and use webhooks only as the low-latency nudge.
Signing & verification
The signature is HMAC-SHA256(secret, "{timestamp}.{raw_body}"), hex-encoded,
carried as v1= in X-IdentSphere-Signature. Verify it against the raw
request body before parsing:
import crypto from 'crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const t = parts.t;
const v1 = parts.v1;
// Reject anything older than 5 minutes — protects against replay.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
// constant-time compare
return (
v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
);
}
Same scheme as Stripe / GitHub — the timestamp prefix defeats replay.
Event types
Event names are snake_case (matching the event field and the
X-IdentSphere-Event-Type header). Each is fanned out from one or more audit
actions:
event | Fired by (audit action) |
|---|---|
user_created | auth.register |
session_created | auth.login, auth.email_otp.login, auth.passkey.login, auth.login.saml |
session_revoked | auth.logout, auth.refresh.reuse_detected (family revoked on theft) |
mfa_enrolled / mfa_disabled | auth.mfa.enabled / auth.mfa.disabled |
passkey_added / passkey_removed | auth.passkey.added / auth.passkey.removed |
password_changed | auth.password_reset.consumed |
member_invited | members.invite |
member_joined | members.invitation_accepted |
member_removed | members.remove |
member_role_changed | members.change_role |
The mapping is identsphere_webhooks::event_type_for; the full set is the
EventType enum in identsphere_webhooks.
Local development
Point an endpoint at webhook.site or an ngrok tunnel to inspect live
deliveries from a localhost (or staging) IdentSphere instance — the signature
scheme is identical to production.
Custom binaries
Hosts building their own server binary can construct the dispatcher directly instead of using the env var:
use identsphere_webhooks::{WebhookDispatcher, WebhookEndpoint};
let dispatcher = WebhookDispatcher::new(vec![WebhookEndpoint {
url: "https://hooks.example.com/identsphere".into(),
secret: b"whsec_abc123".to_vec(),
}]);
let audit_service = AuditService::new(db).with_observer(std::sync::Arc::new(dispatcher));