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:
1 | |
tis a Unix timestamp (seconds).v1is the lowercase hex HMAC-SHA256.
The message being signed is:
1 | |
Where {raw_body} is the exact bytes of the request body, not a re-serialized version.
Verification#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
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 | |
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, useINSERT ... ON DUPLICATE KEY UPDATE(or SQLAlchemy's upsert pattern). Repeateduser.createdevents for the same user are no-ops. - Soft delete on delete events.
user.deletedsets status toinactiverather 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-Idand reject duplicates.
Event payloads#
The outer envelope is consistent:
1 2 3 4 5 6 | |
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#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
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#
1 2 3 4 5 6 7 | |
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
200quickly. Within a few seconds. Long-running processing should happen asynchronously (push to a queue, process later). - Return
200even on processing errors. Returning5xxcauses ScaiKey to retry, which usually doesn't fix whatever went wrong. Log the error and return200withsuccess: falseinstead. - Return
401on 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:
1 | |
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#
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
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:
1 2 | |
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 nestedtenantdata instead. ScaiDNS's upsert logic does this automatically.
Related#
- Webhooks reference — event types and basic shape.
- Tenants and Users — what the sync is keeping current.