Skip to content
Scalekit Docs
Talk to an Engineer Dashboard

Enforce seat limits with SCIM provisioning

Block over-quota user creation and alert admins when SCIM pushes users beyond your plan seat limit.

SCIM (System for Cross-domain Identity Management) provisioning runs unsupervised. When a customer’s HR system pushes user #51 to a 50-seat plan, your application will create that user unless you explicitly block it. Scalekit delivers the provisioning events; your application decides whether to act on them.

This cookbook shows the two-event pattern that keeps your seat count accurate and tells admins when they need to upgrade their plan.

SCIM does not enforce seat limits — your app must

Section titled “SCIM does not enforce seat limits — your app must”

Scalekit translates IdP-specific provisioning protocols into a consistent set of webhook events. It does not know your billing model, your seat limits, or which organizations have room for more users. That logic lives in your application.

When a user is added in the IdP, Scalekit fires organization.directory.user_created. When a user is removed or deactivated, Scalekit fires organization.directory.user_deleted. Your webhook handler is the gate between those events and your user table.

Two webhook events carry the full user lifecycle

Section titled “Two webhook events carry the full user lifecycle”

Both events include the organization_id, which lets you look up the seat limit for that specific customer.

EventWhen it firesWhat to do
organization.directory.user_createdIdP adds or activates a userCheck count — create user or block and notify
organization.directory.user_deletedIdP removes or deactivates a userDecrement count — clear any blocked-provisioning flag

Track a user count per organization in your database

Section titled “Track a user count per organization in your database”

Add a table that stores the provisioned user count and seat limit for each organization. The examples below use plain SQL — translate to your ORM if preferred.

db/schema.sql
CREATE TABLE org_seat_usage (
org_id TEXT PRIMARY KEY,
seat_limit INTEGER NOT NULL,
used_seats INTEGER NOT NULL DEFAULT 0
);

Seed this table when you onboard a new customer. Update seat_limit whenever the customer upgrades or downgrades their plan.

Block creation when the count reaches the limit

Section titled “Block creation when the count reaches the limit”

The user_created handler increments the seat counter and creates the user only when there is room. Always return 200 to Scalekit — returning an error code causes Scalekit to retry delivery, which does not help when the block is intentional.

webhook-handler.ts
import express from 'express'
const app = express()
app.use(express.json())
app.post('/webhooks/scalekit', async (req, res) => {
const event = req.body
if (event.type === 'organization.directory.user_created') {
const orgId = event.organization_id
const directoryUser = event.data
let seatLimitReached = false
// Run the check and insert in a single transaction.
// FOR UPDATE inside the transaction holds the lock until commit.
await db.transaction(async (tx) => {
const usage = await tx.queryOne(
'SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE',
[orgId]
)
if (!usage || usage.used_seats >= usage.seat_limit) {
seatLimitReached = true
return
}
await tx.query(
'INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)',
[directoryUser.id, orgId, directoryUser.email, directoryUser.name]
)
await tx.query(
'UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1',
[orgId]
)
})
if (seatLimitReached) {
// Seat limit reached — skip user creation and alert the admin.
await notifyAdminSeatLimitReached(orgId)
}
}
// Return 200 so Scalekit does not retry this event.
res.sendStatus(200)
})

Decrement the count when a user is removed

Section titled “Decrement the count when a user is removed”

The user_deleted handler decreases the seat counter and clears any pending seat-limit notification. This lets the next user_created event succeed without manual intervention from your team.

webhook-handler.ts
if (event.type === 'organization.directory.user_deleted') {
const orgId = event.organization_id
const directoryUser = event.data
await db.transaction(async (tx) => {
// Remove the user and decrement the counter atomically.
await tx.query('DELETE FROM users WHERE id = $1', [directoryUser.id])
await tx.query(
'UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) WHERE org_id = $1',
[orgId]
)
// Clear any pending seat-limit notification so the next user can be provisioned.
await tx.query(
"DELETE FROM notifications WHERE org_id = $1 AND type = 'seat_limit_reached'",
[orgId]
)
})
}

A new user_created event fires for every blocked user. Without deduplication, your admin will receive one email per rejected provisioning attempt. Use an idempotent insert to fire the notification only once per organization until the condition is resolved.

db/schema.sql
CREATE TABLE notifications (
id SERIAL PRIMARY KEY,
org_id TEXT NOT NULL,
type TEXT NOT NULL,
resolved BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (org_id, type, resolved)
);

The UNIQUE (org_id, type, resolved) constraint blocks duplicate active notifications. Insert with ON CONFLICT DO NOTHING to skip the insert when a notification already exists:

notify.ts
async function notifyAdminSeatLimitReached(orgId: string) {
// Insert only if no unresolved notification exists for this org.
const result = await db.query(
`INSERT INTO notifications (org_id, type, resolved)
VALUES ($1, 'seat_limit_reached', FALSE)
ON CONFLICT (org_id, type, resolved) DO NOTHING`,
[orgId]
)
// rowCount is 0 when the conflict was skipped — admin already notified.
if (result.rowCount === 0) return
// Send the alert once: email, Slack, in-app — your choice.
await sendAdminAlert(orgId, 'Seat limit reached — users are not being provisioned.')
}

When a user is removed and the count drops below the limit, the user_deleted handler deletes the notification row. The next blocked user_created event will insert a fresh notification and trigger a new alert.


Related guides