koala73/worldmonitor · error · ConvexError
SEAT_CAP_REACHED
SEAT_CAP_REACHED
Error message
SEAT_CAP_REACHED
What it means
Thrown by the `inviteSeats` mutation when adding the new (non-duplicate) invitees would exceed the Business plan hard cap of 4 seats (MAX_SEATS). Both accepted grants and un-expired pending grants count toward the limit, so outstanding invites still hold a slot. The check runs under a per-subscription OCC lock (businessSeatLocks) to prevent two concurrent invite calls from racing past the cap.
Source
Thrown at convex/payments/businessSeats.ts:211
if (existing.status === "accepted") {
results.push({ email, grantId: existing._id, status: "already_accepted" });
continue;
}
if (existing.status === "pending" && existing.expiresAt > now) {
results.push({ email, grantId: existing._id, status: "already_pending" });
continue;
}
}
newEmails.push(email);
}
const currentCount = await countActiveOrPendingGrants(
ctx,
businessSub.dodoSubscriptionId,
now,
);
if (currentCount + newEmails.length > MAX_SEATS) {
throw new ConvexError({ kind: "SEAT_CAP_REACHED" });
}
for (const email of newEmails) {
const grantId = await ctx.db.insert("businessProGrants", {
businessSubscriptionId: businessSub.dodoSubscriptionId,
ownerUserId: userId,
inviteeEmail: email,
domain: ownerDomain,
status: "pending",
createdAt: now,
expiresAt: now + INVITE_TTL_MS,
});
const token = await signBusinessInviteToken(grantId);
await ctx.scheduler.runAfter(
0,
internal.payments.businessSeats.sendBusinessInviteEmail,
{ inviteeEmail: email, ownerEmail, grantId, token },View on GitHub (pinned to ffec79ac33)
Solutions
- Revoke an unused pending grant via `removeSeat` before inviting new teammates to free a slot
- Reduce the number of emails in the `inviteSeats` call so currentCount + newEmails.length <= 4
- Wait for a pending invite to pass its 14-day expiry, which releases its slot automatically
- If more than 4 seats are genuinely required, upgrade the plan or contact support — the cap is not adjustable client-side
Defensive patterns
Strategy: validation
Validate before calling
// Before calling inviteSeats, query the count of active/pending grants for the
// Business subscription and ensure count + newEmails.length <= 4 (MAX_SEATS).
const grants = await convex.query(api.payments.businessSeats.listGrantsForOwner);
const active = grants.filter(g => g.status === 'accepted' || (g.status === 'pending' && g.expiresAt > Date.now()));
const room = 4 - active.length;
if (newEmails.length > room) {
// surface 'remove a seat or reduce invite count' to the user instead of calling
} Try / catch
try {
await convex.mutation(api.payments.businessSeats.inviteSeats, { emails });
} catch (err) {
if (err.data?.kind === 'SEAT_CAP_REACHED') {
// show 'seat cap reached' UI; offer to revoke a pending invite
} else { throw err; }
} Prevention
- Show the current active+pending seat count in the invite UI before submission
- Encourage owners to revoke unused pending invites to free slots
- Remember pending (un-expired) invites consume a slot until they expire
When it happens
Trigger: Calling `inviteSeats({ emails })` where `countActiveOrPendingGrants(...) + newEmails.length > 4`. For example, 3 accepted seats plus a 2-email invite, or 4 pending invites plus any new email. Duplicate/already-pending emails are deduped before the count, so they do not consume additional slots.
Common situations: Owner tries to invite a 5th teammate without first revoking an unused pending invite; forgets that pending invites consume a slot until they expire (14-day TTL); batch-inviting more users than remaining capacity in a single call.
Related errors
AI-assisted analysis of koala73/worldmonitor@ffec79ac33 (2026-08-12).
Data as JSON: /api/errors/e9dbf4078ecf6fb3.
Report an issue: GitHub.