Usage Limits

Monthly per-channel quota enforcement, separate from burst rate limiting.

Files

  • src/lib/api/usage.tsincrementUsage() UPSERTs the monthly counter.
  • src/lib/api/usage-limit.tscheckUsageLimit() reads org plan + current period count.
  • src/lib/billing/plans.ts — limit values.
  • src/lib/billing/comped.ts — owners granted the uncapped tier in code.
  • src/lib/api/org-plan.tsresolveOrgPlan(), the cached plan every gate reads.
  • scripts/verify-comped-accounts.ts — read-only check that a comp resolves end to end.
  • Schema: usage_records — composite unique on (org_id, channel, environment, period).

Enforcement

On every POST /api/v1/{emails,sms} with a live environment key:

const usage = await checkUsageLimit(ctx.orgId, "email");
if (!usage.allowed) return 429 USAGE_LIMIT_EXCEEDED;

Test keys bypass this check.

SMS is counted in segments, not messages

Carriers (and AWS SNS) bill per 140-byte segment, so a long SMS is several billable units. incrementUsage for SMS is called with smsSegments(body) (src/lib/api/sms-segments.ts — GSM-7: 160 septets single / 153 concatenated; UCS-2: 70 / 67 UTF-16 units), and every pre-flight gate — single (enforceUsageLimit), batch, and audience blast — measures the send in that same segment unit so the gate matches what actually gets metered. A plan's smsPerMonth is therefore a segment allowance: on Free (500), one 480-character GSM-7 message costs 4. Email stays one unit per recipient.

The count is readable. It is stamped on the message row at send time (messages.segments) and returned as segments on the POST /v1/sms response, on GET /v1/sms/{id}, and on each row of GET /v1/sms — so an invoice can be reconciled against the same number that was billed. It was previously computed at the metering gate and discarded, which left segment-based billing unverifiable from the API.

Two rules keep the reported number and the billed number identical:

  • Every SMS write path stamps the value it metered by — single send, batch (buildMessageRow), and both audience blasts, which stamp per recipient because rendered bodies differ.
  • The scheduled-send cron bills billedSegments(row), which prefers the stored value over recomputing from body. Recomputation could disagree with what the enqueue-time gate reserved and what the API already reported.

segments is written on failed rows too, so it is never an unexplained NULL; whether a row was billed follows from status. Rows enqueued before migration 0041 carry NULL and fall back to recomputation.

Only Free is a hard cap. On batch and audience blasts, just like single sends, Pro overages (billed) and Enterprise is unbounded — the batch gate (evaluateBatchUsage) blocks the Free plan only.

Plan behavior

Plan allowed logic
free used < limit — hard cap
pro true — allowed to overage, billed separately
payg true — no allowance, every unit metered
enterprise true — unlimited

An org whose owner is comped resolves to enterprise here regardless of its plan_status, so it is never hard-capped and never metered for overage. See Billing → Comped accounts.

Counter lifecycle

incrementUsage():

INSERT INTO usage_records (..., period: "2026-04", count: 1)
ON CONFLICT (org_id, channel, environment, period)
DO UPDATE SET count = count + 1, updated_at = now()

Period rolls over automatically on month change (YYYY-MM composite). No explicit reset job.

Known limitations

  • Batch metering — batch sends increment usage once per batch, by the sum of billable units across sent items (email: item count; SMS: summed segments via processBatch's usageUnits). See ../api/emails.md.
  • Failed sends count as attempts — usage is only incremented on success in single sends, but the error path writes the failed message row (not usage). Aligned.
  • No overage metering to Stripe — the pro plan's overage.email $0.001 / SMS $0.01 is defined but not reported to Stripe for invoicing (future work).

Response when quota hit

{
  "error": {
    "type": "rate_limit_error",
    "message": "Free plan limit reached (3000/3000 emails). Upgrade to Pro for more.",
    "code": "USAGE_LIMIT_EXCEEDED"
  }
}

Status 429.