Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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.

Signature format#

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

text
1
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:

scdoc
1
{timestamp}.{raw_body}

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

Verification#

python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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
1
2
3
4
5
6
{
  "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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
{
  "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
1
2
3
4
5
6
7
{
  "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:

text
1
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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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
1
2
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.
Updated 2026-05-17 02:38:20 View source (.md) rev 1