> **Building with AI coding agents?** If you're using an AI coding agent, install the official Scalekit plugin. It gives your agent full awareness of the Scalekit API — reducing hallucinations and enabling faster, more accurate code generation.
>
> - **Claude Code**: `/plugin marketplace add scalekit-inc/claude-code-authstack` then `/plugin install <auth-type>@scalekit-auth-stack`
> - **GitHub Copilot CLI**: `copilot plugin marketplace add scalekit-inc/github-copilot-authstack` then `copilot plugin install <auth-type>@scalekit-auth-stack`
> - **Codex**: run the bash installer, restart, then open Plugin Directory and enable `<auth-type>`
> - **Skills CLI** (Windsurf, Cline, 40+ agents): `npx skills add scalekit-inc/skills --list` then `--skill <skill-name>`
>
> `<auth-type>` / `<skill-name>`: `agentkit`, `full-stack-auth`, `mcp-auth`, `modular-sso`, `modular-scim` — [Full setup guide](https://docs.scalekit.com/dev-kit/build-with-ai/)

---

# Enforce seat limits with SCIM provisioning

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.
**Full Stack Auth handles this automatically:** This pattern applies to **Modular SCIM** customers who manage their own user database. If you use Scalekit Full Stack Auth, seat enforcement is built in — you don't need this cookbook.

## 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

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

| Event | When it fires | What to do |
|-------|---------------|------------|
| `organization.directory.user_created` | IdP adds or activates a user | Check count — create user or block and notify |
| `organization.directory.user_deleted` | IdP removes or deactivates a user | Decrement count — clear any blocked-provisioning flag |

## 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.

```sql title="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

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.
**Verify webhook signatures before processing:** Always verify that events come from Scalekit before acting on them. An unverified endpoint that mutates your database can be triggered by forged requests. See the [SCIM provisioning quickstart](/directory/scim/quickstart/) for how to verify signatures using the Scalekit SDK.
**Keep the lock inside the transaction:** The `SELECT ... FOR UPDATE` must run inside the same explicit transaction as the `INSERT` and `UPDATE`. In autocommit mode, a `FOR UPDATE` outside a transaction is released immediately after the select — it provides no protection against concurrent writes.

```ts title="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)
    })
    ```
  ```python title="webhook_handler.py"
    from flask import Flask, request

    app = Flask(__name__)

    @app.route('/webhooks/scalekit', methods=['POST'])
    def handle_webhook():
        event = request.get_json()

        if event.get('type') == 'organization.directory.user_created':
            org_id = event['organization_id']
            directory_user = event['data']
            seat_limit_reached = False

            # Run the check and insert in a single transaction.
            # FOR UPDATE inside the transaction holds the lock until commit.
            with db.transaction() as tx:
                usage = tx.query_one(
                    'SELECT seat_limit, used_seats FROM org_seat_usage '
                    'WHERE org_id = %s FOR UPDATE',
                    (org_id,)
                )

                if not usage or usage['used_seats'] >= usage['seat_limit']:
                    seat_limit_reached = True
                else:
                    tx.execute(
                        'INSERT INTO users (id, org_id, email, name) VALUES (%s, %s, %s, %s)',
                        (directory_user['id'], org_id,
                         directory_user['email'], directory_user['name'])
                    )
                    tx.execute(
                        'UPDATE org_seat_usage SET used_seats = used_seats + 1 '
                        'WHERE org_id = %s',
                        (org_id,)
                    )

            if seat_limit_reached:
                # Seat limit reached — skip user creation and alert the admin.
                notify_admin_seat_limit_reached(org_id)

        # Return 200 so Scalekit does not retry this event.
        return '', 200
    ```
  ```go title="webhook_handler.go"
    package main

    import (
      "encoding/json"
      "net/http"
    )

    func webhookHandler(w http.ResponseWriter, r *http.Request) {
      var event map[string]interface{}
      if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
        http.Error(w, "bad request", http.StatusBadRequest)
        return
      }

      if event["type"] == "organization.directory.user_created" {
        orgID := event["organization_id"].(string)
        data := event["data"].(map[string]interface{})
        seatLimitReached := false

        // Run the check and insert in a single transaction.
        // FOR UPDATE inside the transaction holds the lock until commit.
        tx, _ := db.Begin()
        var seatLimit, usedSeats int
        err := tx.QueryRow(
          "SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = $1 FOR UPDATE",
          orgID,
        ).Scan(&seatLimit, &usedSeats)

        if err != nil || usedSeats >= seatLimit {
          seatLimitReached = true
          tx.Rollback()
        } else {
          tx.Exec(
            "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, $4)",
            data["id"], orgID, data["email"], data["name"],
          )
          tx.Exec(
            "UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = $1",
            orgID,
          )
          tx.Commit()
        }

        if seatLimitReached {
          // Seat limit reached — skip user creation and alert the admin.
          notifyAdminSeatLimitReached(orgID)
        }
      }

      // Return 200 so Scalekit does not retry this event.
      w.WriteHeader(http.StatusOK)
    }
    ```
  ```java title="WebhookController.java"
    import org.springframework.web.bind.annotation.*;
    import java.util.Map;
    import java.util.concurrent.atomic.AtomicBoolean;

    @RestController
    public class WebhookController {

      @PostMapping("/webhooks/scalekit")
      public ResponseEntity<Void> handleWebhook(@RequestBody Map<String, Object> event) {
        if ("organization.directory.user_created".equals(event.get("type"))) {
          String orgId = (String) event.get("organization_id");
          Map<String, Object> directoryUser = (Map<String, Object>) event.get("data");
          AtomicBoolean seatLimitReached = new AtomicBoolean(false);

          // Run the check and insert in a single transaction.
          // FOR UPDATE inside the transaction holds the lock until commit.
          transactionTemplate.execute(status -> {
            OrgSeatUsage usage = db.queryForObject(
              "SELECT seat_limit, used_seats FROM org_seat_usage WHERE org_id = ? FOR UPDATE",
              OrgSeatUsage.class, orgId
            );

            if (usage == null || usage.getUsedSeats() >= usage.getSeatLimit()) {
              seatLimitReached.set(true);
              return null;
            }

            db.update(
              "INSERT INTO users (id, org_id, email, name) VALUES (?, ?, ?, ?)",
              directoryUser.get("id"), orgId,
              directoryUser.get("email"), directoryUser.get("name")
            );
            db.update(
              "UPDATE org_seat_usage SET used_seats = used_seats + 1 WHERE org_id = ?",
              orgId
            );
            return null;
          });

          if (seatLimitReached.get()) {
            // Seat limit reached — skip user creation and alert the admin.
            notifyAdminSeatLimitReached(orgId);
          }
        }

        // Return 200 so Scalekit does not retry this event.
        return ResponseEntity.ok().build();
      }
    }
    ```
  ## 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 events are delivered at least once:** Scalekit may deliver the same `user_deleted` event more than once. The `GREATEST(used_seats - 1, 0)` guard prevents the counter from going below zero, but it does not prevent double-decrements on duplicate events. For high-reliability systems, track processed event IDs using `event.id` from the webhook payload and skip events you have already handled.

```ts title="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]
        )
      })
    }
    ```
  ```python title="webhook_handler.py"
    if event.get('type') == 'organization.directory.user_deleted':
        org_id = event['organization_id']
        directory_user = event['data']

        with db.transaction() as tx:
            # Remove the user and decrement the counter atomically.
            tx.execute('DELETE FROM users WHERE id = %s', (directory_user['id'],))
            tx.execute(
                'UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) '
                'WHERE org_id = %s',
                (org_id,)
            )
            # Clear any pending seat-limit notification so the next user can be provisioned.
            tx.execute(
                "DELETE FROM notifications WHERE org_id = %s AND type = 'seat_limit_reached'",
                (org_id,)
            )
    ```
  ```go title="webhook_handler.go"
    if event["type"] == "organization.directory.user_deleted" {
      orgID := event["organization_id"].(string)
      data := event["data"].(map[string]interface{})

      tx, _ := db.Begin()
      // Remove the user and decrement the counter atomically.
      tx.Exec("DELETE FROM users WHERE id = $1", data["id"])
      tx.Exec(
        "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.
      tx.Exec(
        "DELETE FROM notifications WHERE org_id = $1 AND type = 'seat_limit_reached'",
        orgID,
      )
      tx.Commit()
    }
    ```
  ```java title="WebhookController.java"
    if ("organization.directory.user_deleted".equals(event.get("type"))) {
      String orgId = (String) event.get("organization_id");
      Map<String, Object> directoryUser = (Map<String, Object>) event.get("data");

      transactionTemplate.execute(status -> {
        // Remove the user and decrement the counter atomically.
        db.update("DELETE FROM users WHERE id = ?", directoryUser.get("id"));
        db.update(
          "UPDATE org_seat_usage SET used_seats = GREATEST(used_seats - 1, 0) WHERE org_id = ?",
          orgId
        );
        // Clear any pending seat-limit notification so the next user can be provisioned.
        db.update(
          "DELETE FROM notifications WHERE org_id = ? AND type = 'seat_limit_reached'",
          orgId
        );
        return null;
      });
    }
    ```
  ## Notify admins without spamming them

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.

```sql title="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:

```ts title="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.')
    }
    ```
  ```python title="notify.py"
    def notify_admin_seat_limit_reached(org_id: str) -> None:
        # Insert only if no unresolved notification exists for this org.
        result = db.execute(
            """INSERT INTO notifications (org_id, type, resolved)
               VALUES (%s, 'seat_limit_reached', FALSE)
               ON CONFLICT (org_id, type, resolved) DO NOTHING""",
            (org_id,)
        )

        # 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.
        send_admin_alert(org_id, 'Seat limit reached — users are not being provisioned.')
    ```
  ```go title="notify.go"
    func notifyAdminSeatLimitReached(orgID string) {
      // Insert only if no unresolved notification exists for this org.
      result, _ := db.Exec(
        `INSERT INTO notifications (org_id, type, resolved)
         VALUES ($1, 'seat_limit_reached', FALSE)
         ON CONFLICT (org_id, type, resolved) DO NOTHING`,
        orgID,
      )

      // RowsAffected is 0 when the conflict was skipped — admin already notified.
      rows, _ := result.RowsAffected()
      if rows == 0 {
        return
      }

      // Send the alert once: email, Slack, in-app — your choice.
      sendAdminAlert(orgID, "Seat limit reached — users are not being provisioned.")
    }
    ```
  ```java title="NotificationService.java"
    public void notifyAdminSeatLimitReached(String orgId) {
      // Insert only if no unresolved notification exists for this org.
      int rows = db.update(
        "INSERT INTO notifications (org_id, type, resolved) " +
        "VALUES (?, 'seat_limit_reached', FALSE) " +
        "ON CONFLICT (org_id, type, resolved) DO NOTHING",
        orgId
      );

      // rows is 0 when the conflict was skipped — admin already notified.
      if (rows == 0) return;

      // Send the alert once: email, Slack, in-app — your choice.
      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**

- [SCIM provisioning quickstart](/directory/scim/quickstart/) — set up webhooks and the Directory API, including signature verification
- [Directory webhook events reference](/reference/webhooks/directory-events/) — full event payload schemas

---

## More Scalekit documentation

| Resource | What it contains | When to use it |
|----------|-----------------|----------------|
| [/llms.txt](/llms.txt) | Structured index with routing hints per product area | Start here — find which documentation set covers your topic before loading full content |
| [/llms-full.txt](/llms-full.txt) | Complete documentation for all Scalekit products in one file | Use when you need exhaustive context across multiple products or when the topic spans several areas |
| [sitemap-0.xml](https://docs.scalekit.com/sitemap-0.xml) | Full URL list of every documentation page | Use to discover specific page URLs you can fetch for targeted, page-level answers |
