InvoiceSpec shapes traffic with three per-plan allowances: a sustained request rate, a bounded burst capacity, and a concurrency cap. Together they protect the invoice engine from bursts and excessive in-flight work while keeping each plan's capacity predictable. Operations govern total paid usage; rate limits govern traffic shape and platform fairness.
Every plan has explicit, authoritative values — no plan is "unlimited".
| Plan | Requests / minute | Burst | Concurrent requests |
|---|---|---|---|
| Free | 10 | 2 | 2 |
| Starter | 60 | 10 | 5 |
| Plus | 120 | 20 | 10 |
| Pro | 300 | 30 | 20 |
Enterprise rate limits are negotiated per customer — contact sales for a tailored allowance. Negotiated per-user overrides are supported separately and take precedence when active.
Enterprise customers can negotiate explicit per-user values that take
precedence over the corresponding Plan default. An override targets exactly
one rate dimension (requests/minute, burst, or concurrency) for one user and
records an explicit positive integer — never null, 0, a negative number,
or any sentinel that would mean "unlimited". Overrides may raise or lower
a limit, and a partial override (only some dimensions negotiated) leaves the
other dimensions at their Plan defaults.
An active override takes precedence over the Plan entitlement while the un-overridden dimensions retain their Plan values. An override with an expiration falls back to the Plan default automatically once it expires — no manual revert is required. The engine resolves the effective value (active override, else Plan default) in the same API-key authentication lookup as the rest of your policy, with no extra round trip per request.
When your effective value changes because an override was added, changed, or expired, the engine reconfigures your bucket preserving proportional usage rather than resetting it to full capacity — the same behavior as a Plan change.
Override creation and changes are an operator/service boundary and are never directly writable by users. The dashboard billing details distinguish a negotiated effective value from a Plan default, but do not expose the operator-only audit metadata (reason, author) behind an override.
All API keys owned by one user draw from a single shared allowance. Creating additional API keys does not multiply your capacity — your throughput is governed by your plan, not by your credential count.
Your effective limits follow your currently effective subscription, not a scheduled future plan. A plan change takes effect at the next billing-period boundary. The engine resolves the three allowances from your plan's entitlements as part of API-key authentication, without an extra lookup on every request.
You can view your effective values at any time on the dashboard Billing page, which shows the requests/minute, burst, and concurrency for your current plan.
Plan quotas apply to the invoice-engine endpoints (/v1/invoice/**). Health,
readiness, OpenAPI/Swagger, and internal service-to-service billing and
capacity routes are outside customer quotas so operational control paths
remain available.
A missing, malformed, non-positive, or out-of-range entitlement is a
platform configuration failure, not a quota reduction. It never implies
unlimited access and never silently applies Free limits to a paid plan.
When the engine cannot resolve your effective policy, it returns
HTTP 503 RATE_LIMIT_CONFIGURATION_UNAVAILABLE — no invoice work is
performed, no operations are charged, and the response includes X-Request-Id
for support. Retry later; if it persists, contact support.
Every successfully authenticated request to /v1/invoice/** consumes exactly
one request token before validation, capacity checks, concurrency
admission, or engine processing. This means a request that is later rejected
by validation or billing still counts against your rate limit — the token was
already consumed. A rate-limited rejection performs no engine work and
acquires no concurrency permit.
When your request-token bucket is empty, the engine returns:
RATE_LIMIT_EXCEEDEDrequestId, errorCode,
message)Retry-After — the rounded-up seconds until one token becomes
available (minimum 1 second)RateLimit-Limit — your plan's burst capacityRateLimit-Remaining — 0 at the rejection pointRateLimit-Reset — the rounded-up seconds until the bucket is fully
refilledNo invoice work is performed and no operations are charged on a 429. The request is still recorded as a single rejected API log entry.
Every authenticated invoice response — including a 429 — carries
RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset for the
request-token bucket. These headers describe only the token bucket and
must not be read as concurrency availability; concurrency is enforced
separately and is not reflected in these headers.
In addition to the request-rate token bucket, InvoiceSpec enforces a per-user concurrency cap that limits the number of in-flight invoice requests at any given moment. This protects engine capacity from excessive parallel work.
Every successfully authenticated request to /v1/invoice/** follows this
order:
A request that is rejected by the concurrency cap does not restore its consumed request-rate token. The token was already spent before the concurrency check — this is intentional and prevents rate-bucket gaming through rapid concurrency-rejected retries.
When your in-flight concurrency cap is full, the engine returns:
CONCURRENCY_LIMIT_EXCEEDEDrequestId, errorCode,
message)Retry-After: 1 — retry after reducing your in-flight parallelismRateLimit-* headers from the request-token bucket (already applied
in step 2 above) remain on the response but describe only the token
bucket, not concurrency availabilityNo validation, capacity operation, or engine work is performed on a concurrency-rejected request. Zero operations are charged. The request is still recorded as a single rejected API log entry.
A concurrency permit is held for the entire duration of the request — from admission through validation, capacity check, engine execution, and response writing — and is released exactly once after the request completes (success, validation failure, capacity rejection, engine failure, timeout, or client disconnect). You do not need to do anything to release a permit; the server handles this automatically.
When you receive a CONCURRENCY_LIMIT_EXCEEDED 429, reduce the number of
in-flight parallel requests before retrying. Unlike rate-limit 429s, the
issue is not how fast you are sending requests but how many are currently
in flight. Wait for one or more existing requests to complete, then retry.
The Retry-After: 1 header is a minimum hint — the real signal is that
your in-flight count must drop below your plan's concurrency cap.
When you receive a 429, wait the number of seconds indicated by
Retry-After before sending the next request. For sustained throughput
above your plan's requests/minute, upgrade to a higher plan or implement
client-side throttling to stay within your allowance.
When your effective plan changes between requests (for example, a Free → Pro upgrade at a billing-period boundary), the engine reconfigures your bucket preserving proportional usage rather than resetting it to full capacity. If you had consumed half of your Free burst, you will have half of your new Pro burst available immediately after the upgrade — you do not get a fresh full bucket.
Before your API key is even validated, InvoiceSpec applies a coarse
per-client-IP rate and concurrency policy to invoice-engine endpoints
(/v1/invoice/**). This protects the engine from unauthenticated abuse —
invalid credentials, credential-stuffing, and brute-force key guessing cannot
consume unbounded hashing and database capacity.
The anonymous policy is fixed and separate from every user's plan allowance: 30 requests per minute, burst 10, and 5 concurrent in-flight requests per trusted client IP. Anonymous attempts never consume or expose a user's plan allowance, and the anonymous capacity pool is completely independent from authenticated per-user pools.
When an anonymous request exceeds the rate or concurrency limit, the engine
returns a generic HTTP 429 with Retry-After and no plan-specific
RateLimit-Limit, RateLimit-Remaining, or RateLimit-Reset headers. The
response does not disclose any user, plan, or quota information — the body
carries RATE_LIMIT_EXCEEDED or CONCURRENCY_LIMIT_EXCEEDED with a generic
message and the X-Request-Id correlation id.
No invoice work is performed and no operations are charged on an anonymous rejection. The request is still recorded as a single rejected API log entry without recording credentials or sensitive request content.
The limiter identity is the trusted client IP. Forwarded client-IP
headers (X-Forwarded-For, X-Real-IP, RFC 7239 Forwarded) are honored
only when the immediate sender is a configured trusted reverse proxy. A
direct client can never evade the anonymous limiter by supplying or rotating
spoofed forwarding headers — when the sender is not a trusted proxy, the
immediate socket peer address is the identity and every forwarding header is
ignored.
When the sender is a trusted proxy, the engine walks the X-Forwarded-For
chain right-to-left, skipping entries that are themselves trusted proxies, and
selects the first non-trusted valid IP as the original client. This defeats
the classic leftmost-spoofing attack: a client behind the proxy that injects a
fake leftmost X-Forwarded-For entry cannot rotate the limiter identity,
because the proxy-appended real client is the rightmost non-trusted entry.
If you deploy InvoiceSpec behind a reverse proxy (nginx, Cloudflare, AWS ALB,
Coolify), configure the proxy's IP or CIDR in
invoicespec.rate-limit.anonymous.trusted-proxies so forwarded headers are
honored. Without this configuration, every request appears to originate from
the proxy's IP and shares one anonymous bucket — which may be too coarse for
your traffic pattern.
The anonymous policy and trusted-proxy list are configurable:
| Property | Default | Description |
|---|---|---|
invoicespec.rate-limit.anonymous.requests-per-minute | 30 | Sustained anonymous rate per trusted client IP |
invoicespec.rate-limit.anonymous.burst-capacity | 10 | Anonymous burst capacity per trusted client IP |
invoicespec.rate-limit.anonymous.concurrent-requests | 5 | Max concurrent anonymous in-flight requests per trusted client IP |
invoicespec.rate-limit.anonymous.trusted-proxies | (empty) | Comma-separated trusted reverse-proxy IPs or CIDR blocks |
Health, readiness, OpenAPI/Swagger, and internal service-to-service billing
or capacity routes are excluded from anonymous throttling — they are not matched
by the /v1/invoice/** security matcher.
The rate and concurrency limiters run in-memory inside the single invoice-engine process (ADR-0016). This section documents the operational consequences of that choice: the single-instance invariant, restart behavior, cache sizing, when to migrate to distributed enforcement, and how quotas are reassessed from telemetry. It is intended for operators and platform engineers, not API clients.
The invoice engine MUST run as exactly one service instance while local in-memory limiter storage is configured. A second replica would create an independent second copy of every User's bucket and bulkhead, doubling the effective allowance and breaking the per-User pool contract. The backend enforces this invariant at startup, not only in documentation: when local storage is selected and the configured replica count is greater than one, the application fails to start with an explicit error. This prevents an accidental multi-replica deployment from silently multiplying quotas.
The replica count is declared in deployment configuration
(invoicespec.rate-limit.replicas, default 1) and validated against the
configured storage mode
(invoicespec.rate-limit.storage, default local). The
guard is a startup validator, not a runtime health check — a process that
passes the guard will not silently start coordinating with a second replica
later.
Because limiter state lives in process memory, a backend restart resets
every User's bucket to full capacity. A User who had consumed their entire
Free burst immediately regains the full burst after a restart. This is an
accepted limitation of the single-instance phase and is observable:
the invoicespec.rate-limit.startup counter increments once per process
start, so operators can alert on restart frequency. Frequent restarts
amplify the fresh-burst effect and are a signal to investigate stability or
migrate to distributed storage.
The reset affects only the in-memory token bucket and bulkhead state. Plan entitlements, Enterprise overrides, operations, and API logs are persisted in Supabase Postgres and are not affected by a restart.
Per-User buckets and bulkheads are held in a bounded Caffeine cache with a configurable maximum size and idle-expiry duration. The cache prevents unbounded heap growth from authenticated User cardinality.
| Property | Default | Description |
|---|---|---|
invoicespec.rate-limit.cache.maximum-size | 100000 | Maximum number of per-User entries before the least-recently-used entry is evicted |
invoicespec.rate-limit.cache.expire-after-access | 15m | Idle-expiry duration — a bucket or bulkhead unused for this long is eligible for eviction |
The default maximum size of 100000 covers the active authenticated User cardinality with headroom; the 15-minute idle expiry reclaims state for Users who have stopped sending traffic. Both values are chosen so that a single instance's heap is bounded regardless of total registered Users, while active Users retain their bucket across normal request spacing.
The cache exposes eviction-pressure as a Micrometer gauge
(invoicespec.rate-limit.cache.eviction-pressure, tagged cache=buckets
and cache=bulkheads), computed as estimated-size / maximum-size. A
pressure approaching 1.0 indicates the bounded store is full and entries
are being evicted; if evictions are non-zero under sustained high pressure,
raise maximum-size. Eviction count and cache size are also exported as
gauges (invoicespec.rate-limit.cache.evictions,
invoicespec.rate-limit.cache.size).
The limiter exports low-cardinality Micrometer metrics so operators can
observe health, performance, and configuration failures. No User ID, raw IP
address, API key, or other unbounded sensitive value is used as a metric
label — the only labels are outcome (admitted/rejected), requests_per_minute (the
Plan's requests-per-minute, a small fixed set), limiter (rate or
concurrency), reason, and cache.
| Metric | Type | Labels | Description |
|---|---|---|---|
invoicespec.rate-limit.requests | counter | outcome, requests_per_minute | Request-rate decisions admitted or rejected, by Plan RPM |
invoicespec.rate-limit.decision | timer | limiter, outcome | Time spent inside the rate or concurrency limiter decision |
invoicespec.rate-limit.configuration.failures | counter | reason | Alertable configuration-failure counter (policy_unavailable from the filter, invalid_entitlement from the resolver) |
invoicespec.rate-limit.cache.evictions | gauge | cache | Caffeine eviction count (buckets / bulkheads) |
invoicespec.rate-limit.cache.size | gauge | cache | Estimated cache size |
invoicespec.rate-limit.cache.eviction-pressure | gauge | cache | size / maximumSize — approaching 1.0 means the store is full |
invoicespec.rate-limit.startup | counter | (none) | One-shot increment per process start — signals a fresh in-memory state |
Alert on a non-zero rate of configuration.failures (either reason) — it
indicates a missing or malformed entitlement that is returning 503 to
authenticated Users. Alert on a rising startup counter to surface restart
frequency.
A second invoice-engine replica is prohibited until request-rate state uses shared Redis/Valkey-backed Bucket4j storage and distributed concurrency has been designed and verified. The migration preserves the project-owned limiter port and the public API contract — only the storage and concurrency adapters and their configuration change. Controllers, filters, error codes, headers, and the per-User pool semantics are unaffected.
Evaluate the migration using measured signals, not assumptions:
decision-duration timer. If the in-memory
decision latency is no longer negligible relative to engine latency, or
if a distributed adapter's added latency is acceptable for the traffic
shape, migration is viable.The Bucket4j Bucket4j port isolates the storage adapter; the future
Redis/Valkey adapter replaces Bucket4jRequestRateLimiter's local Caffeine
store with a distributed proxy manager, and the Resilience4j Bulkhead is
replaced with a distributed semaphore or equivalent. No controller or
filter change is required.
The launch defaults of 10/60/120/300 requests per minute (Free/Starter/Plus/Pro) are retained until evidence supports a change. Reassess quotas from measured telemetry, not assumptions:
rejected rate for a Plan may indicate the
allowance is too low for real usage; a near-zero rate may indicate it is
generous.Paid-plan reductions require reasonable notice except for abuse or incidents. Operations govern total paid usage; rate limits govern traffic shape and platform fairness — changing one does not implicitly change the other.