---
title: 'REST API: Webhooks'
path: reference/rest-api/webhooks
status: published
---

# REST API: Webhooks

ScaiKey-signed webhook receiver. Updates the local user/group mirror in near-real-time on join/leave events.

## `POST /v1/webhooks/scaikey`

ScaiKey posts signed events here. The handler verifies the HMAC-SHA256 signature using `SCAIFLOW_SCAIKEY_WEBHOOK_SECRET`, then dispatches to per-event-type handlers.

**Headers (set by ScaiKey, verified server-side):**

- `X-Scaikey-Signature: t=<timestamp>,v1=<hex_sha256>` — HMAC over `timestamp.body` with the shared secret.
- `X-Scaikey-Timestamp` — used both in the signature payload and for replay-attack protection.
- `Content-Type: application/json`.

**Body** — a ScaiKey webhook envelope:

```jsonc
{
  "type": "user.created",                  // event type
  "id": "evt_xxx",
  "created_at": "2026-04-29T...",
  "data": {                                // type-dependent payload
    "user_id": "usr_xxx",
    "tenant_id": "tnt_acme",
    "email": "alice@acme.example"
  }
}
```

**Response:**

```jsonc
{ "handled": true }                        // or false for unknown event types
```

Unknown event types return 200 + `handled: false` (so ScaiKey doesn't retry them — they're just ignored).

Bad/missing signatures return 400; missing timestamp or wrong secret config return 400/503 respectively.

## Supported event types

| Type | Behavior |
|---|---|
| `user.created` | Upsert the user row in the local mirror. |
| `user.updated` | Upsert (email/name/status changes). |
| `user.deleted` | Soft-delete (preserves admin_role + audit trail). |
| `group.created` | Upsert the group row. |
| `group.updated` | Upsert. |
| `group.deleted` | Soft-delete. |
| `group_membership.added` | Upsert (user_id, group_id) into the memberships table. Triggers super_admin re-reconciliation. |
| `group_membership.removed` | Soft-delete the membership. Triggers super_admin re-reconciliation. |
| `application.group_assigned` | Refresh effective_users + re-reconcile super_admin. Does NOT create a Group row (mirroring is via the regular sync). |
| `application.group_unassigned` | Same as assigned. |

Anything else returns 200 + `handled: false`.

## Fallback: scheduled sync

If webhooks aren't reaching the backend (network issue, ScaiKey misconfiguration), the hourly background sync (interval set by `SCAIFLOW_SYNC_INTERVAL_MINUTES`, default 60) catches up. The CLI bootstrap (`scaiflow scaikey-register`) does the initial sync.

## Configuration

Two env vars on the backend:

- `SCAIFLOW_SCAIKEY_WEBHOOK_SECRET` — the shared secret used for signature verification. Get it from your ScaiKey app's webhook configuration UI.
- (no second env var) — the webhook URL is whatever you configure in ScaiKey's app settings. Typically `https://scaiflow.example/api/v1/webhooks/scaikey`.

If `SCAIFLOW_SCAIKEY_WEBHOOK_SECRET` is unset, the endpoint returns 503 (fail-closed). Set it before pointing ScaiKey at the URL.

## Testing locally

ScaiKey doesn't reach `localhost`. For local development, either:

- Tunnel through ngrok/cloudflared and configure ScaiKey to point at the tunnel URL.
- Trigger sync manually with `POST /v1/admin/sync` (uses the API path, not webhooks).
