---
title: Webhooks Deep Dive
path: tutorials/webhook-integration
status: published
---

# Webhooks Deep Dive

Implementation-level details for the ScaiKey webhook integration: signature verification, event handling, idempotency, and debugging. For the event type catalog and basic shape, see [Webhooks](../reference/webhooks.md).

## Signature format

ScaiKey signs webhook bodies with HMAC-SHA256 and sends the signature in a combined header:

```
X-ScaiKey-Signature: t=1714567890,v1=a1b2c3d4e5f6...
```

- `t` is a Unix timestamp (seconds).
- `v1` is the lowercase hex HMAC-SHA256.

The message being signed is:

```
{timestamp}.{raw_body}
```

Where `{raw_body}` is the exact bytes of the request body, not a re-serialized version.

## Verification

```python
import hashlib
import hmac
import time


def verify_scaikey_signature(header: str, body: bytes, secret: str) -> bool:
    # Parse combined header: t=<timestamp>,v1=<signature>
    parts = dict(p.split("=", 1) for p in header.split(","))
    timestamp = parts["t"]
    signature = parts["v1"]

    # Timestamp freshness (5 minute window)
    ts = int(timestamp)
    if abs(int(time.time()) - ts) > 300:
        return False

    # Compute expected signature
    message = f"{timestamp}.".encode() + body
    expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()

    # Constant-time comparison
    return hmac.compare_digest(expected, signature)
```

```typescript
import { createHmac, timingSafeEqual } from "crypto";

function verifyScaikeySignature(
  header: string,
  body: Buffer,
  secret: string,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const { t: timestamp, v1: signature } = parts;

  // Timestamp freshness
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) return false;

  // Compute expected signature
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex");

  // Constant-time comparison
  return timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(signature, "hex"),
  );
}
```

The ScaiDNS implementation in `app/api/v1/webhooks.py` follows this pattern exactly.

### What to verify

- **Timestamp freshness.** Reject anything outside the 5-minute window. Prevents replay of captured events.
- **HMAC match.** Reject mismatched signatures before parsing the body — this protects against tampering.
- **Raw body.** Verify against the exact bytes as received. Do not parse-then-re-serialize before verification; JSON re-ordering will break the signature.

## Idempotency

Webhooks can be delivered more than once. Your handler must be idempotent.

ScaiDNS's approach:

- **Upsert semantics.** Instead of `INSERT`, use `INSERT ... ON DUPLICATE KEY UPDATE` (or SQLAlchemy's upsert pattern). Repeated `user.created` events for the same user are no-ops.
- **Soft delete on delete events.** `user.deleted` sets status to `inactive` rather than removing the row. A repeat event is a no-op.
- **Event ID for strict dedupe.** If you need exactly-once processing, track `X-ScaiKey-Event-Id` and reject duplicates.

## Event payloads

The outer envelope is consistent:

```json
{
  "event_id": "evt_abc123",
  "event_type": "user.created",
  "timestamp": "2026-04-23T10:42:00Z",
  "data": {}
}
```

Inner `data` varies by event type. ScaiKey's SDK ships type definitions in `scaikey_sdk.webhooks` — use them if you're in Python.

### User events

```json
{
  "data": {
    "id": "usr_abc",
    "email": "user@example.com",
    "username": "user",
    "first_name": "First",
    "last_name": "Last",
    "display_name": "First Last",
    "status": "ACTIVE",
    "assignment_type": "group",
    "tenant": {
      "id": "tnt_xyz",
      "slug": "bbinfra-nl",
      "name": "BBinfra NL"
    },
    "partner": {
      "id": "prt_...",
      "name": "ScaiLabs"
    }
  }
}
```

Tenant info is nested under `tenant`, partner under `partner`. ScaiDNS's sync logic uses this to upsert the tenant row as a side-effect of user events — useful when the `client.tenants.list()` API isn't permitted to return tenants.

### Group member events

```json
{
  "data": {
    "group_id": "grp_abc",
    "user_id": "usr_xyz",
    "role": "member"
  }
}
```

The user and group must already exist locally. If they don't, ScaiDNS attempts a single-entity sync to catch up before processing the membership change.

## Response expectations

- **Return `200` quickly.** Within a few seconds. Long-running processing should happen asynchronously (push to a queue, process later).
- **Return `200` even on processing errors.** Returning `5xx` causes ScaiKey to retry, which usually doesn't fix whatever went wrong. Log the error and return `200` with `success: false` instead.
- **Return `401` on signature mismatch.** Let ScaiKey know the config is wrong so it retries (and an operator notices).

## Debugging

### Verify the webhook is configured

In ScaiDNS logs, look for startup messages like:

```
Webhook signature verification skipped - no secret configured
```

If you see that, `SCAIKEY_WEBHOOK_SECRET` isn't set. Production must have it set.

### Check delivery

In ScaiKey's admin UI, there's typically a webhook activity log. Failed deliveries show the status code and response body ScaiDNS returned.

### Test signature verification locally

```python
import hashlib, hmac, time, json

secret = "your-webhook-secret"
body = json.dumps({"event_type": "user.created", "data": {"id": "u_test"}})
ts = str(int(time.time()))

sig = hmac.new(secret.encode(), f"{ts}.".encode() + body.encode(), hashlib.sha256).hexdigest()
header = f"t={ts},v1={sig}"

print(header)

# Post to ScaiDNS
import httpx
httpx.post(
    "http://localhost:8000/api/v1/webhooks/scaikey",
    headers={"X-ScaiKey-Signature": header, "Content-Type": "application/json"},
    content=body,
)
```

### Replay from a captured event

Keep the raw bytes. If you parse to JSON and re-serialize, the signature won't match even if the data is equivalent.

### Re-sync after missed events

If webhooks were down or misconfigured for a while, run a full sync to catch up:

```bash
scaidns sync                 # Full sync
scaidns sync --users-only    # Users only (faster)
```

This pulls the complete state from ScaiKey and reconciles local data.

## Common pitfalls

- **Secret not set at startup.** Unsigned webhooks are accepted, with a warning. Make sure the secret is set before production.
- **Secret mismatch.** ScaiKey and ScaiDNS must share the exact same secret. A trailing newline or whitespace will break things.
- **Parsing body before verification.** Verify raw, then parse. Re-serialization changes byte-for-byte content.
- **Not handling replays.** Every handler must be idempotent. Test by replaying the same event twice.
- **Tenant events returning empty list from ScaiKey.** If `client.tenants.list()` returns 0 tenants due to API scoping, rely on the user event's nested `tenant` data instead. ScaiDNS's upsert logic does this automatically.

## Related

- [Webhooks reference](../reference/webhooks.md) — event types and basic shape.
- [Tenants and Users](../concepts/tenants-and-users.md) — what the sync is keeping current.
