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.- 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.
Plan behavior
| Plan | allowed logic |
|---|---|
free |
used < limit — hard cap |
pro |
true — allowed to overage, billed separately |
enterprise |
true — unlimited |
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 undercount — batch sends increment usage once per batch (not per message). 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 (100/100 emails). Upgrade to Pro for more.",
"code": "USAGE_LIMIT_EXCEEDED"
}
}
Status 429.