Gareth Wilson Gareth Wilson

Guide to Scalekit Webhooks: Features and Best Practices

Published


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

FeatureDetails
SignatureStandard Webhooks HMAC-SHA256
Signature headerwebhook-signature
BodyJSON
Event routingBody field type
ConfigurationDashboard, Webhooks, per environment
Event selectionPer endpoint
Secretwhsec_, one per endpoint
Format versionspec_version ("1")
Response2xx within 30 seconds; 201 recommended
Retries8 attempts over about 27 hours 35 minutes
Auto-disableAfter 5 days of failures
GuaranteesAt least once, unordered

Events and payloads

The main event families:

EventTriggered When
organization.created / .updated / .deletedAn organisation changes
organization.domain_created / .domain_deletedA domain is added or removed
organization.domain_dns_verification_success / _failedDomain DNS verification finishes
organization.directory_created / _enabled / _disabled / _deletedA SCIM directory changes state
organization.directory.user_created / _updated / _deletedA directory user is provisioned, changed or removed
organization.directory.group_created / _updated / _deletedA directory group changes
organization.sso_created / _enabled / _disabled / _deletedAn SSO connection changes
user.signup / user.login / user.logoutA user signs up, logs in or out through hosted login
user.organization_invitationA user is invited to an organisation
user.organization_membership_created / _updated / _deletedA membership changes
role.created / .updated / .deletedA role changes
permission.created / .updated / .deletedA permission changes
connected_account.created / .updated / .deleted / .status_updatedAn 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.

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:

{
  "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 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 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 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 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 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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.