Guide to Scalekit Webhooks: Features and Best Practices
Scalekit handles authentication for B2B SaaS products: organisations, enterprise SSO, SCIM directory sync, roles and sessions. It also offers AgentKit, which manages the third-party accounts that AI agents connect to and call tools through. Scalekit's webhooks tell your application when any of this changes, which matters most for directory sync, where users are provisioned and deprovisioned from a customer's identity provider without anyone touching your app.
We'll cover Scalekit's event object, the 43 event types, endpoint setup, Standard Webhooks verification and Scalekit's retry and disabling rules.
What are Scalekit webhooks?
Scalekit webhooks are endpoints you add in the dashboard for an environment, each subscribed to a set of events. Deliveries are JSON POST requests signed per Standard Webhooks with webhook-id, webhook-timestamp and webhook-signature. Scalekit's webhook best practices page covers verification, responses and retries.
Every delivery is an event object. Its annotated example, which contains // comments and so isn't strict JSON:
{
"spec_version": "1",
"id": "evt_100000000000000001", // unique per event; differs from webhook-id
"object": "DirectoryUser", // names the data's type; omitted on domain events
"environment_id": "env_100000000000000001",
"occurred_at": "2026-10-01T09:30:00.000Z",
"organization_id": "org_100000000000000001", // not sent on user.* events
"type": "organization.directory.user_created",
"data": { "id": "diruser_100000000000000001", "email": "kai@example.com", "active": true }
}
Route on type. object describes what data contains, not which event happened.
Scalekit webhook features
| Feature | Details |
|---|---|
| Signature | Standard Webhooks HMAC-SHA256 |
| Signature header | webhook-signature |
| Body | JSON |
| Event routing | Body field type |
| Configuration | Dashboard, Webhooks, per environment |
| Event selection | Per endpoint |
| Secret | whsec_, one per endpoint |
| Format version | spec_version ("1") |
| Response | 2xx within 30 seconds; 201 recommended |
| Retries | 8 attempts over about 27 hours 35 minutes |
| Auto-disable | After 5 days of failures |
| Guarantees | At least once, unordered |
Events and payloads
The main event families:
| Event | Triggered When |
|---|---|
organization.created / .updated / .deleted | An organisation changes |
organization.domain_ / .domain_deleted | A domain is added or removed |
organization.domain_ / _failed | Domain DNS verification finishes |
organization.directory_ / _enabled / _disabled / _deleted | A SCIM directory changes state |
organization.directory.user_ / _updated / _deleted | A directory user is provisioned, changed or removed |
organization.directory.group_ / _updated / _deleted | A directory group changes |
organization.sso_created / _enabled / _disabled / _deleted | An SSO connection changes |
user.signup / user.login / user.logout | A user signs up, logs in or out through hosted login |
user.organization_ | A user is invited to an organisation |
user.organization_ / _updated / _deleted | A membership changes |
role.created / .updated / .deleted | A role changes |
permission.created / .updated / .deleted | A permission changes |
connected_ / .updated / .deleted / .status_updated | An AgentKit connected account changes |
AgentKit also sends connected_, connected_, connected_, connected_ and connected_, described in its account events guide.
Some definitions are narrower than the names suggest. directory.user_created also fires when a deactivated user is reactivated, and directory.user_updated fires when a user joins or leaves a group. directory.user_deleted covers deactivation as well as deletion. user.organization_ fires on every SCIM update of a provisioned member.
A connected_ event from the webhook samples library:
{
"spec_version": "1",
"id": "evt_101531404017336586",
"type": "connected_account.created",
"object": "ConnectedAccount",
"occurred_at": "2025-12-01T10:23:52.702980847Z",
"environment_id": "env_88640229614813449",
"data": {
"authorization_type": "OAUTH",
"connection_id": "conn_100668583155073286",
"id": "ca_101531404000559370",
"identifier": "Bruce",
"provider": "CANVA",
"status": "PENDING_AUTH"
}
}
Setting up Scalekit webhooks
- In the Scalekit dashboard, open Webhooks for the environment.
- Add an endpoint with your HTTPS URL, for example
https://your-app.com/webhooks/scalekit. - Subscribe only to what you need, such as the three
organization.directory.user_events for directory sync.* - Save, open the endpoint and copy its
whsec_signing secret intoSCALEKIT_WEBHOOK_SECRET.
Each endpoint has its own secret, and events carry an environment_id, so keep secrets per endpoint rather than per app. To test directory events locally, run the Hookdeck CLI and add its URL as an endpoint in a development environment:
npx hookdeck-cli listen 3000 scalekit --path /webhooks/scalekit
With Hookdeck. Create a Scalekit source and add its URL as an endpoint, then copy that endpoint's whsec_ secret into the source. Hookdeck replies 200 to each POST, well inside Scalekit's 30 seconds, and logs signature failures as failed verification without delivering them. No other methods are accepted.
Securing Scalekit webhooks
Scalekit signs {webhook-id}.{webhook-timestamp}.{raw body} with HMAC-SHA256, keyed with the base64-decoded text after whsec_, and sends one or more space-separated v1,<base64> values. Its docs specifically warn that t=...,v1=<hex>, scalekit-signature and sha256= formats are not Scalekit signatures, so code copied from another provider won't work. This function follows the documented scheme:
const crypto = require('node:crypto');
function decodeBase64(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value)) return null;
const bytes = Buffer.from(value, 'base64');
return bytes.length > 0 && bytes.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '')
? bytes : null;
}
function verifySignature(rawBody, headers, secret) {
if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
const prefix = headers['webhook-id'] !== undefined ? 'webhook' : 'svix';
const id = headers[`${prefix}-id`];
const timestamp = headers[`${prefix}-timestamp`];
const signature = headers[`${prefix}-signature`];
if (typeof id !== 'string' || !id || typeof timestamp !== 'string' ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== 'string') return false;
const key = decodeBase64(secret.replace(/^whsec_/, ''));
if (!key) return false;
const message = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, 'utf8'), rawBody]);
const candidates = signature.split(/\s+/).filter((item) => item.startsWith('v1,')).map((item) => item.slice(3));
const expected = crypto.createHmac('sha256', key).update(message).digest();
return candidates.some((candidate) => {
const received = decodeBase64(candidate);
if (!received) return false;
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
A handler for directory sync that respects Scalekit's recommended 201:
const express = require('express');
// Returns the parsed body when it is a JSON object, otherwise null.
function parseJsonObject(rawBody) {
try {
const value = JSON.parse(rawBody.toString('utf8'));
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
} catch {
return null;
}
}
const app = express();
app.post('/webhooks/scalekit', express.raw({ type: '*/*' }), async (req, res) => {
const secret = process.env.SCALEKIT_WEBHOOK_SECRET;
if (!secret) return res.sendStatus(500);
if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
const event = parseJsonObject(req.body);
if (!event) return res.sendStatus(400); // signed, but not a JSON object
if (typeof event.type !== 'string' || !event.data || typeof event.data !== 'object') return res.sendStatus(400);
try {
if (await events.seen(event.id)) return res.sendStatus(201);
switch (event.type) {
case 'organization.directory.user_created':
case 'organization.directory.user_updated':
await users.upsertIfNewer(event.organization_id, event.data, event.occurred_at);
break;
case 'organization.directory.user_deleted':
await users.deactivate(event.organization_id, event.data.id, event.occurred_at);
break;
default:
break;
}
await events.markSeen(event.id);
} catch {
return res.sendStatus(503); // not processed, so don't acknowledge
}
return res.sendStatus(201);
});
events and users are your own storage. Comparing occurred_at stops an older update from overwriting a newer one.
Scalekit asks for a ±5-minute timestamp check when verifying manually, and its SDK rejects timestamps more than five minutes old. Its docs also warn that an event queued for more than five minutes before a worker verifies it will fail. Hookdeck applies no tolerance and keeps the original webhook-timestamp on its own retries and replays, so services behind it should verify without an age limit, as this function does.
Make Scalekit webhooks production-ready. Hookdeck Event Gateway keeps directory and login events flowing to your app even while it's deploying, so a customer's SCIM changes never pile up into a disabled endpoint.
Scalekit webhook limitations and pain points
Every non-2xx is retried, including 4xx
The Problem: A handler that returns 400 for events it doesn't understand gets the same event eight times.
Why It Happens: Scalekit retries any status other than 2xx.
Workarounds:
- Return
2xx(Scalekit suggests201) for every verified event, including ones you ignore, and401only for bad signatures.
How Hookdeck Can Help: Hookdeck acknowledges every request, so your handler's status codes never trigger Scalekit retries. Hookdeck's own retries follow your rules.
Disabled after five days
The Problem: A long outage disables the endpoint, and provisioning events stop.
Why It Happens: An endpoint that fails every delivery for five days is disabled, and the retry schedule for each event ends after about 27 hours 35 minutes.
Workarounds:
- Re-enable the endpoint from its actions menu and Resend failed messages from its message log.
- After a long gap, list users and groups through the API rather than replaying every event.
How Hookdeck Can Help: Hookdeck receives every event while your service is down, so the Scalekit endpoint keeps succeeding.
Unordered directory events
The Problem: A user update arrives after the user was deleted, and the user reappears.
Why It Happens: Delivery is at least once and can be out of order, and SCIM providers can send changes in quick succession.
Workarounds:
- Compare
occurred_atbefore writing, and keep tombstones for deleted users.
How Hookdeck Can Help: Deduplication removes repeated deliveries; ordering stays in your code.
Two different IDs
The Problem: Deduplication keyed on the wrong field either misses duplicates or drops real events.
Why It Happens: webhook-id stays the same across retries, the body's id is unique per event and differs from it, and data.id identifies the user or organisation.
Workarounds:
- Deduplicate on
webhook-idor the bodyid, never ondata.id.
How Hookdeck Can Help: Hookdeck can deduplicate on either field before your code runs.
Best practices
Subscribe narrowly
Scalekit can send every event, but each endpoint should subscribe only to what it handles. Login events in particular can be frequent.
Verify on arrival
Verify the signature before you queue the event, not in a worker later, so the five-minute window in Scalekit's SDK doesn't reject it.
Check type before reading data
data's shape depends on the event, and organization_id is absent on user.* events. See our guide to webhook idempotency for recording processed IDs alongside these writes.
Reconcile directories periodically
Webhooks keep users current between syncs, but a scheduled list of users and groups catches anything missed.
Conclusion
Scalekit's webhooks keep your app in step with organisations, SSO connections, directory users and AI agents' connected accounts. Verify the Standard Webhooks signature when the request arrives, answer 2xx to everything you accept, order updates by occurred_at, and deduplicate on webhook-id or the event id.
Hookdeck Event Gateway protects the endpoint from being disabled and gives you replay when something goes wrong. Sign up for Hookdeck and add a Scalekit source to your development environment.