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

  1. Revoke an unused pending grant via `removeSeat` before inviting new teammates to free a slot
  2. Reduce the number of emails in the `inviteSeats` call so currentCount + newEmails.length <= 4
  3. Wait for a pending invite to pass its 14-day expiry, which releases its slot automatically
  4. 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

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.