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.
  • 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.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 (100/100 emails). Upgrade to Pro for more.",
    "code": "USAGE_LIMIT_EXCEEDED"
  }
}

Status 429.