---
title: Webhooks
path: reference/webhooks
status: published
---

# 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
{
  "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).

## 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
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

**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`

## 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).
