Appearance
Rate limits
All Krafter APIs are rate-limited. Authenticated routes are keyed per API key team; public unauthenticated routes are keyed per client IP. Each route belongs to one of the buckets below, and counters in different buckets are independent — exceeding one bucket does not affect another for the same caller.
Buckets
| Bucket | Limit | Applied to |
|---|---|---|
default | your plan's rate × 60s | Every authenticated /api/v1/... endpoint via the :api_authenticated pipeline. |
strict | 30 req / 60s | Expensive endpoints — flag evaluate / bootstrap / track, push subscriber registration, audit scans run, public surveys. |
public | 200 req / 60s | Public unauthenticated endpoints — form submissions, public analytics ingest, webhook ingest. |
log_ingest | 300 req / 60s | Log ingest endpoints, keyed per project ingest token. |
flags_client_ip | 120 req / 60s | Flags client-key endpoints (/api/v1/flags/client/...), keyed per client IP. |
flags_client | your plan's rate × 60s | The same endpoints, keyed per client key rather than per team — so browser traffic cannot spend the budget your server-side traffic depends on. |
The default bucket follows your plan
The default bucket is sized from the API rate your plan sells, over the same 60-second window:
| Plan | API rate | default bucket |
|---|---|---|
| Free | 10 req/s | 600 req / 60s |
| Starter | 50 req/s | 3,000 req / 60s |
| Pro | 100 req/s | 6,000 req / 60s |
| Scale | 500 req/s | 30,000 req / 60s |
| Enterprise | unlimited | not rate limited |
Unauthenticated requests have no plan to read and fall back to 100 req / 60s.
The fixed-limit buckets cap an endpoint rather than a customer: an expensive query is expensive on every plan.
Some endpoints pipe through both default and strict. In that case each bucket counts independently — the request will fail when either counter exceeds its limit. The response headers (x-ratelimit-*) only reflect the last bucket evaluated (strict overwrites default), so a request near the default ceiling can still succeed and report the remaining strict budget without surfacing how close it is to the default cap. Per-endpoint docs include a "Rate limit" callout when a non-default bucket is in play.
Response headers
Every API response includes the bucket counters in lowercase headers:
x-ratelimit-limit— the bucket's configured limit (e.g.100)x-ratelimit-remaining— requests left in the current windowx-ratelimit-reset— Unix timestamp (seconds) when the window resets
Exceeding the limit (429)
When a client exceeds its bucket, the response is:
- HTTP
429 Too Many Requests retry-after: <seconds>header (RFC 9110)- JSON body:
{"error": "Rate limit exceeded", "retry_after": <seconds>}
http
HTTP/1.1 429 Too Many Requests
content-type: application/json
retry-after: 23
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1714128360
{"error": "Rate limit exceeded", "retry_after": 23}retry_after is the time left in the current window, rounded up to a whole second and never 0. Wait that long and the next request lands in a fresh window, at or after x-ratelimit-reset.
Audit API exception
The Audit API normally wraps responses in the {data, meta, error} envelope, but its :strict_rate_limit 429 responses use the platform shape above (no envelope). See Audit overview → Rate-limit responses for details.
Monthly plan quota
The rate limit above is not the only thing that returns 429. Your plan also caps how much of each service you may use per month, and that cap is a different counter with a different reset and a different fix.
| Rate limit | Plan quota | |
|---|---|---|
| Window | 60 seconds | Calendar month, UTC |
| Resets | On the next minute boundary | On the 1st of the month |
| Body | {"error": "Rate limit exceeded", "retry_after": 23} | {"error": "Monthly usage limit exceeded. Upgrade your plan."} |
| Fix | Retry after the window | Raise the plan, or send less |
A 429 carrying retry_after will clear on its own. A 429 that says Monthly usage limit exceeded will not — retrying it is pointless until the month rolls over.
Checking what is left
GET /api/v1/usage reports this month's usage against your plan for every metered service:
bash
curl https://app.krafter.dev/api/v1/usage \
-H "Authorization: Bearer kr_live_abc123def456"json
{
"period_start": "2026-09-01T00:00:00Z",
"period_end": "2026-10-01T00:00:00Z",
"plan": "pro",
"services": {
"mail": {"metric": "emails_sent", "used": 9812, "limit": 10000, "remaining": 188, "unlimited": false},
"forms": {"metric": "submissions", "used": 41, "limit": 1000, "remaining": 959, "unlimited": false}
}
}On a plan with no ceiling for a service, limit and remaining are null and unlimited is true — so remaining is never a number you have to second-guess.
Scopes. A key sees the services it is scoped for and no others: a mail:send key gets services.mail alone. billing:read returns every service. A key with none of these scopes gets 403.
Warnings before the wall
Krafter emails the team owner when a service passes 80% and again at 95% of its monthly limit — once per threshold, per service, per month. The mail names the service, the numbers, and what runs out.
You do not have to subscribe to anything, and the warning is sent by the platform, so it does not consume the quota it is warning you about.
Shed load before the cap, not after
If a service is on a critical path — sign-in codes, for instance — poll /api/v1/usage and stop sending what you can do without (digests, reminders) while there is still room for what you cannot.
Window semantics
Windows are fixed-window: counters reset on a clock boundary derived from (unix_timestamp / window_seconds) * window_seconds. A client that hits the bucket at second 59 of a 60-second window will see counters reset 1 second later, not 60 seconds later.
Because reset is wall-clock aligned (not sliding), a burst at the end of one window followed by a burst at the start of the next can temporarily exceed the per-minute average — by design, to keep the limiter cheap.
Disabling for tests
The rate limiter checks Application.get_env(:krafter, :rate_limit_enabled, true) — when the value is false, the plug is a no-op. Production has it enabled; the test environment disables it so test suites are not throttled.