# Guide to Scalekit Webhooks: Features and Best Practices

[Scalekit](https://scalekit.com) 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](https://docs.scalekit.com/guides/webhooks-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:

```jsonc
{
  "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_created` / `.domain_deleted` | A domain is added or removed |
| `organization.domain_dns_verification_success` / `_failed` | Domain DNS verification finishes |
| `organization.directory_created` / `_enabled` / `_disabled` / `_deleted` | A SCIM directory changes state |
| `organization.directory.user_created` / `_updated` / `_deleted` | A directory user is provisioned, changed or removed |
| `organization.directory.group_created` / `_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_invitation` | A user is invited to an organisation |
| `user.organization_membership_created` / `_updated` / `_deleted` | A membership changes |
| `role.created` / `.updated` / `.deleted` | A role changes |
| `permission.created` / `.updated` / `.deleted` | A permission changes |
| `connected_account.created` / `.updated` / `.deleted` / `.status_updated` | An AgentKit connected account changes |

AgentKit also sends `connected_account.magic_link_generated`, `connected_account.oauth_tokens_fetched`, `connected_account.oauth_succeeded`, `connected_account.token_refresh_succeeded` and `connected_account.token_refresh_failed`, described in its [account events guide](https://docs.scalekit.com/agentkit/account-events.md).

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_membership_updated` fires on every SCIM update of a provisioned member.

A `connected_account.created` event from the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/scalekit/latest/connected_account.created.json):

```json
{
  "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

1. In the Scalekit dashboard, open **Webhooks** for the environment.
2. Add an endpoint with your HTTPS URL, for example `https://your-app.com/webhooks/scalekit`.
3. Subscribe only to what you need, such as the three `organization.directory.user_*` events for directory sync.
4. Save, open the endpoint and copy its `whsec_` signing secret into `SCALEKIT_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](/docs/cli) and add its URL as an endpoint in a development environment:

```bash
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:

```javascript
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`:

```javascript
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](/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 suggests `201`) for every verified event, including ones you ignore, and `401` only 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](/docs/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_at` before writing, and keep tombstones for deleted users.

**How Hookdeck Can Help:** [Deduplication](/docs/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-id` or the body `id`, never on `data.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](/webhooks/guides/implement-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](https://hookdeck.com) protects the endpoint from being disabled and gives you replay when something goes wrong. [Sign up for Hookdeck](https://dashboard.hookdeck.com/signup) and add a Scalekit source to your development environment.