Usage Limits
Monthly per-channel quota enforcement, separate from burst rate limiting.
Files
src/lib/api/usage.ts—incrementUsage()UPSERTs the monthly counter.src/lib/api/usage-limit.ts—checkUsageLimit()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.ts—resolveOrgPlan(), 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 frombody. 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'susageUnits). 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.01is 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.