Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Modellen Tools & Services
Oplossingen
Organisaties Ontwikkelaars Internet Service Providers Managed Service Providers AI-in-a-Box
Kenniscentrum
Ondersteuning Documentation Blog Downloads
Bedrijf
Over ons Onderzoek Vacatures Investeren Contact
Inloggen

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:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "event_id":   "evt_<nanoid>",
  "event_type": "<event-name>",
  "timestamp":  "<iso 8601 UTC>",
  "resource":   { "type": "<resource-type>", "id": "<resource-id>" },
  "actor":      { "id": "<user-id|null>", "type": "admin|user|scim|system|api" },
  "data":       { /* per-event payload, see below */ },
  "tenant_id":  "tnt_<id>",
  "partner_id": "prt_<id>"
}

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). tenant_id is absent on platform/partner-scoped events, so treat it as optional.

The canonical object id is always resource.id#

For every event, resource.id is the id of the object the event is about, and resource.type names it — one of partner, tenant, user, group, application. Read the id from resource.id, not from data. (data carries the object's attributes; for partner.*/tenant.* it happens to also include an id, but user.*/group.* do not — so resource.id is the only field that is present and canonical across all of them.)

actor describes who caused the event (actor.id is a usr_ id, or null for a system/unauthenticated actor; actor.type ∈ admin|user|scim|system|api) — it is provenance, not the subject.

Two-object events put the primary object in resource and the other id in data:

Event resource.type resource.id other id in data
user.* user the user (usr_) —
group.created/updated/deleted group the group (grp_) —
group.member_added / group.member_removed group the group (grp_) data.user_id = the affected user
group.nested_added / group.nested_removed group the parent group data.child_group_id = the child group
application.user_assigned / ..._unassigned application the application (app_) data.application_id, data.user_id, data.user_email, data.user_display_name
application.group_assigned / ..._unassigned application the application (app_) data.application_id, data.group_id

For the application.*_assigned / _unassigned events the primary object is the application (resource.type = "application", resource.id = app_…); the user or group affected is in data (data.user_id / data.group_id). Read the assignment from data, not resource.

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.

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import hmac, hashlib, time

def verify(headers, body_bytes, secret):
    sig_header = headers.get("X-ScaiKey-Signature", "")
    parts = dict(p.split("=", 1) for p in sig_header.split(","))
    ts = int(parts["t"])
    received = parts["v1"]

    # Reject if timestamp is too old (replay window)
    if abs(time.time() - ts) > 300:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),
        f"{ts}.{body_bytes.decode('utf-8')}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(received, expected)

Event types#

The full enum is in backend/src/scaikey/services/events.py. Headline list:

Partner / tenant lifecycle:

  • partner.created, partner.updated, partner.deleted
  • tenant.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.deleted
  • user.suspended, user.activated (dedicated events, unlike tenants)
  • user.password_changed, user.mfa_enabled, user.mfa_disabled

Group lifecycle:

  • group.created, group.updated, group.deleted
  • group.member_added, group.member_removed — one event per user added/removed in batch operations
  • group.nested_added, group.nested_removed — for group-of-groups

group.member_removed does not mean the user lost access to your app. The event says a group membership changed, not whether that withdrew an entitlement — the user may still reach your application directly, or via another group. To decide whether access actually changed, re-read the application's effective users after the event: GET /admin/applications/{app_id}/effective-users (SDK applications.get_effective_users()). An application may call it for its own users with its ordinary client_credentials token — no admin scope — so it's the natural companion to the membership events. The same holds for application.group_unassigned.

Application lifecycle:

  • application.created, application.updated, application.deleted
  • application.user_assigned, application.user_unassigned
  • application.group_assigned, application.group_unassigned

Session and auth events:

  • session.created, session.terminated
  • auth.login_success, auth.login_failed, auth.logout

Registration requests:

  • registration_request.created, registration_request.approved, registration_request.rejected

New event types are added over time — treat the set as open. A subscriber to a * or family (user.*) glob will receive event types newer than the one it was built against. Handle an unrecognized event_type by ignoring or logging it, never by failing the delivery: returning a non-2xx triggers ScaiKey's retries and raises your endpoint's consecutive_failures (see Reliability). The Python SDK does this for you as of 1.1.9 — WebhookEvent.type is WebhookEventType | str, so a known type is the enum and a newer one arrives as a plain string rather than raising.

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
application.user_assigned / ..._unassigned application_id, application_name, user_id, user_email, user_display_name
application.group_assigned / ..._unassigned application_id, application_name, group_id, group_name

Delete semantics#

Deletions arrive as the dedicated *.deleted event (user.deleted, group.deleted, tenant.deleted, partner.deleted), with the deleted object identified by resource.id and a small data snapshot of its former attributes (e.g. user.deleted carries email, display_name). Deletes are soft server-side (the row is retained with a deleted_at), but that is an internal detail — from a consumer's view the object is gone; drop it from your cache on *.deleted. A suspend is not a delete: it emits the dedicated user.suspended (and re-activation user.activated), and a status change made via a plain update emits user.updated with data.status. Tenant/partner suspension has no dedicated event — it is *.updated with data.status = "SUSPENDED".

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#

Delivery & retries. Each event is delivered by POST. A non-2xx response, a connection error, or a timeout is a failed attempt, retried on a fixed schedule. At the default retry limit there are 3 attempts total: the initial delivery, a retry ~1 minute later, and a retry ~5 minutes after that (retry delays [60s, 300s]). If the third attempt fails, the delivery is marked failed permanently and the webhook's consecutive_failures counter increments; the admin UI surfaces per-webhook delivery stats (success_rate, avg_response_time_ms). There is no indefinite retry — the whole window is ~6 minutes — so a consumer that needs to catch up after a longer outage should reconcile with a periodic full sync (the REST list endpoints), with webhooks as the low-latency path on top.

Signature. X-ScaiKey-Signature: t=<unix_ts>,v1=<hex> where v1 = HMAC-SHA256(secret, "<t>.<raw-body>"). The timestamp is regenerated on each attempt (a retry is re-signed with a fresh t), so validate against your own clock with a tolerance for skew + delivery latency — 300 seconds is a reasonable window (what the Python SDK uses by default). Verify over the exact raw bytes you received, before parsing.

De-duplication. event_id is globally unique and stable across retries (the same event carries the same event_id on every attempt), so it is a safe idempotency key. A subscriber may also receive the same event on more than one delivery if it matches multiple subscriptions; event_id dedupes those too.

Ordering. None is guaranteed. Deliveries run through a background worker and independent retry timers, so events can arrive out of order (a retried event can land after a later one). Treat the stream as unordered and reconcile using timestamp — for a given resource, apply an event only if its timestamp is newer than the last state you recorded, and let the periodic full sync settle anything missed.

Your endpoint should:

  • Return 2xx within 10 seconds (timeout is 10s by default, per-webhook configurable up to 30).
  • Be idempotent, keyed on event_id — duplicate deliveries are possible during retry and across overlapping subscriptions.
  • Verify the signature before doing any work (defense against forged calls).
Updated 2026-09-27 14:47:46 View source (.md) rev 10