Webhooks
ScaiKey emits webhooks for partner, tenant, user, group, application, session, registration, and authentication events. Webhooks are delivered with retry, signed with HMAC-SHA256, and queued through a background worker.
Envelope#
Every payload follows the same shape, regardless of event type:
1 2 3 4 5 6 7 8 9 10 | |
tenant_id is present for tenant-scoped events; partner_id for partner-level events. Both can be present (tenant events carry the owning partner_id too).
Delivery headers#
Each delivery includes:
| Header | Example | Meaning |
|---|---|---|
Content-Type |
application/json |
|
X-ScaiKey-Webhook-ID |
4291 |
Numeric delivery row id (for replay correlation) |
X-ScaiKey-Event-ID |
evt_a3f9k2bWqL8Hn5pZ |
The same event_id from the body |
X-ScaiKey-Event-Type |
tenant.created |
The same event_type from the body |
X-ScaiKey-Timestamp |
1747584000 |
Unix seconds; matches the signature timestamp |
X-ScaiKey-Signature |
t=1747584000,v1=<hex> |
HMAC-SHA256 — see below |
User-Agent |
ScaiKey-Webhook/1.0 |
Signature verification#
The signature is HMAC-SHA256(secret, "{timestamp}.{body}") where {body} is the raw request body bytes. The body is canonically formatted as json.dumps(payload, separators=(",", ":"), sort_keys=True) — your verifier must compare against the bytes actually received, not a re-serialized version.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Event types#
The full enum is in backend/src/scaikey/services/events.py. Headline list:
Partner / tenant lifecycle:
partner.created,partner.updated,partner.deletedtenant.created,tenant.updated,tenant.deleted
There's no partner.suspended or tenant.suspended — suspending one of those emits the .updated event with data.status = "SUSPENDED".
User lifecycle:
user.created,user.updated,user.deleteduser.suspended,user.activated(dedicated events, unlike tenants)user.password_changed,user.mfa_enabled,user.mfa_disabled
Group lifecycle:
group.created,group.updated,group.deletedgroup.member_added,group.member_removed— one event per user added/removed in batch operationsgroup.nested_added,group.nested_removed— for group-of-groups
Application lifecycle:
application.created,application.updated,application.deletedapplication.user_assigned,application.user_unassignedapplication.group_assigned,application.group_unassigned
Session and auth events:
session.created,session.terminatedauth.login_success,auth.login_failed,auth.logout
Registration requests:
registration_request.created,registration_request.approved,registration_request.rejected
Sample payloads#
Synthetic but accurate payload examples for every event family are committed in the open-source backend repo at docs/integration/sample-webhooks/. Use them as fixture data for your translator's tests — they reflect the exact data shape per event.
Per-event data field shape#
A compact reference for the fields inside data. Tenant_id and partner_id sit in the envelope, not in data.
| Event | data keys |
|---|---|
partner.created |
id, name, slug, status |
partner.updated |
echo of PATCH body (any subset of name, slug, status) |
partner.deleted |
id, name, slug |
tenant.created |
id, name, slug, partner_id, status |
tenant.updated |
echo of PATCH body, minus settings and branding (filtered for size) |
tenant.deleted |
id, name, slug, partner_id |
user.created |
email, display_name, first_name, last_name, status |
user.updated |
echo of PATCH body, minus password |
user.deleted |
email, display_name |
group.created |
name, description, group_type |
group.updated |
echo of PATCH body |
group.deleted |
name |
group.member_added / group.member_removed |
group_name, user_id |
group.nested_added / group.nested_removed |
parent_group_id, parent_group_name, child_group_id, child_group_name |
Routing model#
ScaiKey supports two webhook scopes:
TENANT-scoped webhooks — register a URL on a tenant; receive every event in that tenant.APPLICATION-scoped webhooks — register a URL on a tenant plus an application_id; receive only events that involve users or groups assigned to that application.
A subscription's events list accepts exact event names (user.created), wildcards (user.*), or universal (*).
Per-app sync webhooks#
Separate from the subscription model: an application can configure a sync_webhook_url + sync_webhook_secret on its row to receive events about its assigned users/groups without registering an explicit subscription. Useful for downstream apps that just want directory mirroring without going through the full webhook UI.
Sync webhook secrets are stored as plaintext (signing uses the raw value, unlike subscription webhooks where the secret is hashed).
Reliability#
Failures are retried automatically with backoff at 1, 5, and 15 minutes (3 attempts total). After permanent failure, the delivery is marked failed and the webhook's consecutive_failures counter increments. The admin UI surfaces delivery stats per webhook (success_rate, avg_response_time_ms).
Your endpoint should:
- Return 2xx within 10 seconds (timeout, configurable up to 30).
- Be idempotent — duplicate deliveries are possible during retry.
- Verify the signature before doing any work (defense against forged calls).