Enforcing Seat Limits and Overage at the API Edge

A seat limit that is checked with a SELECT count(*) and then acted on is not a limit; it is a suggestion that usually holds. Two administrators clicking “invite” at the same moment both read nine of ten seats used, both proceed, and the account ends up with eleven members while being billed for ten. This guide sits under Entitlements & Feature Gating and covers how to enforce countable limits correctly at the point requests enter the system, and what to do when the business would rather bill for the overage than block it.

The decision that comes first is not technical. For each countable feature, does exceeding the limit block the action or bill for it? Blocking protects the pricing model and creates friction; billing captures expansion revenue and creates surprise invoices. Most products want blocking for seats and soft overage for consumption — and the two need different machinery, so the choice has to be made per feature rather than globally.

Trade-offs

Policy Customer experience Revenue effect Complexity Fits
Hard block at limit Clear, occasionally frustrating Drives upgrades Low — one atomic check Seats, editors, projects
Soft overage, billed Frictionless, risk of bill shock Captures expansion Medium — needs rating API calls, storage, events
Soft overage with cap Frictionless up to a ceiling Captures most expansion Higher — two thresholds Metered products with volatile usage
Grace then block Forgiving, delays the decision Weakest Medium — needs a timer Trials, first overage only
Limit enforcement policies Hard block drives upgrades with friction, soft overage bills silently, capped overage bounds the risk, and grace defers the decision. Hard block 402 at the edge upgrade prompt seats, projects Soft overage allow and rate bill next cycle API calls Capped overage allow to a ceiling then block volatile usage Grace then block one free breach then enforce trials
The policy is a per-feature commercial decision — the enforcement machinery differs enough that mixing them by accident is expensive.

The capped-overage row is worth more attention than it usually gets. It is the policy that protects both sides: the customer cannot accumulate an unbounded invoice, and you cannot serve unbounded free capacity. Implementing it means two thresholds — the included limit and the hard ceiling — and a notification between them, which is exactly the shape described in handling usage spikes and bill shock caps.

Step-by-Step Implementation

1. Put the check at one boundary

Enforcement scattered across service methods drifts. Put it in middleware that resolves entitlements once per request, and expose two helpers: one for booleans, one for countable consumption. Every service then calls the helper rather than reasoning about limits.

export function requireSeatAvailable() {
  return async (req: Request, res: Response, next: NextFunction) => {
    const { accountId } = req.auth;
    const limit = req.entitlements["seats"];             // null = unlimited
    if (limit === null) return next();
    const reserved = await seats.reserveOrFail(accountId, limit, req.idempotencyKey);
    if (!reserved) {
      return res.status(402).json({
        error: "seat_limit_reached",
        limit,
        upgrade_url: "/billing/upgrade?reason=seats",
      });
    }
    res.locals.seatReservationId = reserved.id;
    next();
  };
}

2. Reserve atomically, do not count-then-write

The reservation must be a single statement whose success or failure is decided by the database, not by application logic reading a count and deciding.

-- One statement: the INSERT only happens if capacity exists at that instant.
INSERT INTO seat_reservations (account_id, invitation_id, reserved_at)
SELECT $1, $2, now()
 WHERE (
   SELECT count(*) FROM account_members WHERE account_id = $1
 ) + (
   SELECT count(*) FROM seat_reservations
    WHERE account_id = $1 AND released_at IS NULL
 ) < $3                                    -- the entitlement limit
RETURNING reservation_id;

Zero rows returned means no capacity. Under READ COMMITTED two concurrent executions can still both observe capacity, so pair this with a serialising lock on the account row or run the transaction at SERIALIZABLE and retry on conflict. The lock scope is deliberately per account: contention exists only where the invariant does.

3. Release reservations deterministically

A reservation that is never released is a seat permanently lost to the customer, which produces support tickets that are hard to diagnose. Release on three triggers — invitation accepted (converted to a member), invitation revoked, invitation expired — and add a sweeper for the case where all three are missed.

UPDATE seat_reservations
   SET released_at = now(), release_reason = 'expired'
 WHERE released_at IS NULL
   AND reserved_at < now() - INTERVAL '14 days';

4. Emit the billing mutation on acceptance, not on invite

Billing for an invitation that is never accepted is a refund waiting to happen. The quantity change belongs at the moment membership becomes real.

def on_invitation_accepted(account_id: str, invitation_id: str) -> None:
    with db.transaction():
        db.convert_reservation_to_member(account_id, invitation_id)
        new_qty = db.count_members(account_id)
        billing.set_subscription_quantity(
            account_id, price_key="seats", quantity=new_qty,
            idempotency_key=f"seats:{account_id}:{new_qty}:{invitation_id}",
            proration_behavior="create_prorations",
        )

Whether the quantity change prorates immediately or waits for the next cycle is a pricing decision, not a technical one, and it interacts directly with calculating prorated charges for mid-cycle upgrades.

5. Reconcile enforced against billed

Seat reconciliation Members plus open reservations are compared against the billed subscription quantity, and any divergence is alerted rather than auto-corrected. Members in product Open reservations Enforced count Billed quantity compare divergence alerts a human — auto-correction hides the bug that caused it
Reconcile nightly and alert rather than self-heal; a silent correction removes the only evidence of the underlying defect.

Handling Overage Without Bill Shock

Where the policy is soft overage, the enforcement point changes from “block” to “record and notify”. The counter still has to be atomic, because the recorded number becomes an invoice line, but the return value no longer gates the request. What replaces the block is a notification ladder: at 80% of the included allowance, at 100%, and at each subsequent multiple of the overage unit.

The notification that actually prevents disputes is the one sent when overage begins, not when the invoice arrives. A customer who is told on the 12th that they have entered paid overage and will owe roughly a known amount by month end is a customer who either accepts it or acts. The same customer discovering it on the invoice will contact support, and the conversation usually ends in a credit.

NOTIFY_AT = (0.8, 1.0)   # fractions of the included allowance

def record_usage(store, account_id: str, feature: str, limit: float, n: int, period: str):
    used = store.incrby(f"quota:{account_id}:{feature}:{period}", n)
    if limit:
        for frac in NOTIFY_AT:
            crossed = used >= limit * frac and used - n < limit * frac
            if crossed:
                notifications.enqueue(account_id, "usage_threshold", feature=feature, fraction=frac)
    return used

Firing the notification only on the crossing edge — comparing the value before and after the increment — is what keeps a burst of requests from generating a burst of emails.

Overage notification ladder Alerts fire at eighty percent of the included allowance, at the allowance itself, and again at the hard ceiling where requests begin to be refused. Included allowance · no charge Paid overage · rated Ceiling · refuse 80% notice 100% notice: overage has begun ceiling notice
Three notices, each on the crossing edge — the one at 100% is what prevents the invoice from being the customer's first signal.

Choosing where the ceiling sits is a commercial decision that benefits from being conservative on first contact. A ceiling at twice the included allowance bounds the customer’s worst case at a number they can absorb, and an account that repeatedly reaches it is a strong upgrade signal rather than a support problem. Setting the ceiling far above realistic usage — or omitting it — trades a small amount of captured revenue for the occasional invoice that gets written off in full.

When the Limit Drops Below Current Usage

Every countable limit eventually faces the awkward case: the account is using more than the new limit allows. It happens on downgrade, on a contract that was not renewed at the same size, and on a plan whose included allowance was reduced for new customers and then applied to existing ones by mistake. There are exactly three defensible policies, and the failure is choosing none of them.

Block the downgrade until usage fits. The customer is told they must remove five members before moving to the ten-seat plan. This keeps the invariant “usage never exceeds limit” true at all times, which makes every downstream query simple. It also produces a self-service dead end for customers who genuinely want to spend less, and a support ticket for anyone who cannot find the member list.

Allow the downgrade and enforce read-only above the limit. Existing members stay, no new ones can be added, and the account is over its limit until attrition or removal brings it back. This is the kindest option and the one that most products should choose, but it requires every consuming query to tolerate an over-limit state rather than assuming it is impossible.

Allow the downgrade and auto-remove. The system picks which five members to deactivate. This is almost never right — the choice is arbitrary, it destroys access silently, and the customer discovers it when someone cannot log in.

Whichever is chosen, express it in the enforcement layer rather than in the downgrade endpoint, so that an over-limit state reached by any other path — a contract that lapsed, an entitlement grant that expired — behaves the same way. The rule to encode is about the current relationship between usage and limit, not about the event that created it.

def can_add_member(used: int, limit: int | None) -> tuple[bool, str]:
    if limit is None:
        return True, ""
    if used < limit:
        return True, ""
    if used > limit:
        return False, "over_limit_reduce_first"    # already above, e.g. after downgrade
    return False, "seat_limit_reached"

Distinguishing “at the limit” from “over the limit” in the returned reason matters for the message the customer sees. At the limit, the right prompt is to upgrade. Over the limit, the right prompt is to remove members or upgrade — and telling an over-limit account to “upgrade to add a seat” when they just downgraded deliberately reads as a system that is not paying attention.

Verification & Testing

The concurrency test is non-negotiable and easy to write: with a limit of ten and nine members, fire twenty simultaneous invitation requests and assert exactly one succeeds. Run it against a real database rather than a mock, because the property under test is the database’s isolation behaviour, not your code’s. If the test passes against a mock and fails against Postgres, the mock was the problem; if it passes against Postgres at READ COMMITTED without a lock, the test is not concurrent enough.

Test the release paths individually: accept, revoke, expire, and the sweeper. For each, assert that the reserved count returns to its prior value and that a subsequent invite succeeds. The expiry case needs an injectable clock rather than a sleep.

Finally, test the reconciliation itself by deliberately corrupting one side — set the billed quantity to a wrong value in a test fixture — and assert that the job reports the divergence rather than silently fixing it. A reconciliation job that has never been observed failing is a job nobody knows the failure output of.

Gotchas & Production Pitfalls

  • Counting members with a soft-deleted flag. Deactivated users that still hold a row will inflate the enforced count. Decide whether a deactivated member occupies a seat and make the query say so explicitly.
  • Reservations leaked by a crashed request. If the reservation is created outside the transaction that creates the invitation, a crash between the two leaves an orphan. Keep them in one transaction and add the sweeper anyway.
  • Quantity set from the reservation count. Billing on members plus reservations charges for invitations that may never convert. Bill on members only.
  • Unlimited represented as a large number. Using 999999 for unlimited means an account eventually hits it, and the failure looks like a genuine limit. Use NULL and branch on it.
  • Downgrade below current usage. An account with fifteen members downgrading to a ten-seat plan needs an explicit policy: block the downgrade, require removal first, or allow it and enforce read-only above the limit. Silently leaving fifteen active seats on a ten-seat plan is the outcome nobody chose.

Frequently Asked Questions

Should a blocked request return 402 or 403? 402 for billing-driven denials, 403 for permission denials. The distinction lets clients render an upgrade path and lets you measure monetisable friction separately from authorisation bugs.

Where should the counter live for very high request rates? In an in-memory store on the request path, folded into durable storage asynchronously. Enforcing against a Postgres row per request serialises on that row and adds latency proportional to contention.

What happens if the entitlement store is unavailable? For seats, fail closed — the cost of blocking an invite for a few minutes is far lower than the cost of an account silently exceeding its paid limit. For low-risk quotas, failing open is defensible. Decide per feature and document it.

Do reservations need to be visible to the customer? Showing “8 of 10 seats used, 1 invitation pending” removes an entire class of confusion, because the customer’s own count of active people will not match yours otherwise.