epok

Limits, quotas and error codes

Updated Jul 28, 2026 · 1d ago

Every number on this page comes from a configuration key in the running system, not from a pricing deck. Where enforcement differs from what a header or a marketing sentence implies, this page says so — the point of a limits page is that you find out here rather than at 3am.

Read this first: there are two body caps, not one

The single most common surprise is a batch size that works against one endpoint and 413s against another. The caps are genuinely different because the planes are different services:

PlaneCapApplies to
Logs & traces
/api/v1/ingest, /v1/logs, /v1/traces, _bulk, loki, syslog, and every shipper endpoint
2 MBChecked twice: the compressed body as it arrives, and the decompressed body. A 1.5 MB gzip that expands to 9 MB is rejected. Configurable per deployment via max_body_size_mb; the default is 2.
Metrics
/v1/metrics
8 MBMeasured after gzip decompression. Rejection detail reads metrics payload too large (8MB cap).
Prometheus remote write
/api/v1/write
8 MB / 64 MB8 MB compressed on the wire, 64 MB after decompression, and a separate ceiling of 500,000 samples per request.

You can work out before you send whether a batch will be accepted: measure the bytes you are about to POST andthe bytes they decompress to, and compare against the row above. Most shippers let you set this directly — Vector's batch.max_bytes, Fluent Bit's buffer chunk size. Set it below the cap rather than discovering the cap.

The billable unit

Bytes of request body accepted at ingest, summed across signals, per UTC day. Not events, not spans, not time series. The daily counter is unified: logs, traces and metrics all meter into one total for your account, so shifting volume from one signal to another does not change what you owe. The counter resets at 00:00 UTC, and it survives restarts on our side — a redeploy does not hand you a fresh quota.

Reads are not part of this unit. Searches, dashboards, live tail and alert evaluation cost nothing by volume; they are bounded only by the query rate limit below.

Per-plan limits

PlanSustained ingestDaily volumeQuery rateRetention
Trial500 entries/s73 GB1,200/min14 days
Team500 entries/s34 GB1,200/min30 days
Growth2,000 entries/s135 GB3,000/min30 days
CustomPer contractPer contractUnlimitedUp to 365 days
Trial expired100 entries/s0 — ingest blocked300/min14 days

Trial is 73 GB/day rather than Team's 34 GB deliberately: it is sized so a 14-day trial can absorb roughly 1 TB, which lets you stress-test above the volume you will actually pay for.

What is not gated by plan:the detectors. Every tier — including an expired trial — runs the full detector set on whatever data it has. There is no “intelligence” upsell; the plan buys volume, retention, seats and AI budget, not analysis.

Ingest rate: a token bucket, not a ceiling

The “500 entries/s” figure above is a sustained refill rate, not a per-second cap. If you size your batches against it you will leave most of your headroom unused. The real shape:

  • Burst capacity is 5× the rate. A 500/s plan holds a bucket of 2,500 entries; a 2,000/s plan holds 10,000. An idle shipper that reconnects can spend the whole bucket in one go.
  • The bucket is allowed to go negative by another 5× the rate. Debt is absorbed and refills over time. From full, that is roughly 10× the sustained rate — 5,000 entries on a 500/s plan — before rejection is even possible.
  • A single request that fits inside the burst is never rejected by the per-tenant limiter. Rejection requires all three of: an empty bucket, debt deeper than the burst window, and a batch larger than the burst itself. A 2,500-entry batch on a 500/s plan is never rejected by this limiter regardless of how far behind you are.

The practical guidance: size batches at or just under 5× your plan's per-second rate and the per-tenant limiter will not reject them. In practice the 2 MB body cap binds before this does. Because the third condition is a hard requirement, sustained overload on its own is not enough to produce a rate_limited 429 from this path — it takes sustained overload and an oversized batch together. When one does fire, its Retry-After is computed from your actual deficit, so it is trustworthy.

A separate platform-wide limiter (default 50,000 entries/s across all tenants, same 5× burst) exists to protect the write path. It has no fits-inside-the-burst exemption, so it can reject a small batch that the per-tenant limiter would have waved through. You are unlikely to meet it, but it means a 429 is not always caused by your own traffic.

The daily volume quota is enforced, on every plan

Be clear about this one, because it is the limit that behaves least like people expect. On the logs and traces plane, the daily byte quota is checked before the batch is parsed and there is no paid-tier exemption in that path. Once your account's unified daily total passes the plan's cap, further ingest requests return HTTP 429 with a quota message until the counter resets. It is a hard stop, not an overage meter.

The Retry-After header on this 429 is misleading. The quota rejection carries retry_after: 60, but the counter it refers to resets at midnight UTC — not in sixty seconds. A shipper that obeys the header will spin against a closed door for the rest of the day. Detect the quota message and back off against midnight UTC instead; the retry snippet below shows the check.

Metrics meter into the same unified daily total, so a metrics-heavy day consumes the same budget as a log-heavy one. If you expect to exceed your plan's daily volume, move up a plan or drop volume at the source before the ceiling — filtering debug logs at the shipper is almost always the cheapest fix, and the Kubernetes setup shows where to put that filter.

HTTP status codes

Rejections come back in JSON, but in two shapes, and a client that assumes one will crash on the other. Logs and traces return an error slug alongside a detail sentence; the metrics endpoints return detail only, with no error key. Read detail — it is the one field present everywhere — and treat error as optional.

CodeCauseRemedy
400
Bad Request
The body did not parse: malformed NDJSON, a corrupt compressed stream, or a Content-Encoding that is not supported — gzip, zstd and snappy are accepted, deflate and br are rejected by name.Do not retry — the same bytes will fail again. The detail field names the specific problem. The metrics endpoint accepts both OTLP encodings, JSON and protobuf, so a stock collector's default is fine; a 400 here means the bytes did not parse as either.
401
Unauthorized
No API key was found on the request in any accepted form.Send the key as a header. See Authentication for every accepted form. Not retryable.
403
Forbidden
A key was found but rejected: it is invalid or rotated, its scopes do not include ingest, or the tenant is disabled. Also returned by plan gates — see below.Read the detail: “Invalid API key” and “does not have required scope: ingest” are different problems. Authentication covers both. Not retryable.
413
Payload Too Large
The request body exceeded the plane's cap — 2 MB on logs and traces, 8 MB on metrics. Checked before parsing, and again after decompression.Split the batch. On logs and traces the detail carries both numbers in bytes; on metrics it names the cap only. Not retryable as-is.
429
Too Many Requests
Four different things share this code: the per-tenant rate limiter, the daily volume quota, write-ahead-log pressure, and downstream backpressure. The error field distinguishes them — rate_limited, wal_full, backpressure.Back off on the header for wal_full (10s) and backpressure (5s). For rate_limited, check whether the detail mentions a quota before trusting the header — see the retry section.
503
Service Unavailable
Either the key-lookup path could not reach its database and had no cached answer (“Authentication service temporarily unavailable”), or you hit a deliberate plan gate — see below. Gates return 503 with a sentence naming the feature.A 503 whose detail names a feature is not an outage and will not clear on retry. A 503 that mentions authentication is transient — retry with backoff.
what the rejections actually look like
json
# 429 from the per-tenant rate limiter — clears in seconds
{"error": "rate_limited", "detail": "Rate limit exceeded", "retry_after": 3}

# 429 from the daily volume quota — does NOT clear in 60s despite the header
{"error": "rate_limited",
 "detail": "Daily volume limit exceeded (78383154012 of 78383153152 bytes)",
 "retry_after": 60}

# 429 after the trial expires — no backoff will fix this
{"error": "rate_limited", "detail": "daily ingest quota is 0 for this tier (trial expired) — add a payment method to resume ingest", "retry_after": 60}

# 413 on logs/traces — a COMPRESSED body that expanded past the cap
{"error": "payload_too_large", "detail": "Decompressed size 9437184 exceeds limit 2097152"}

# 413 on logs/traces — an UNCOMPRESSED body over the cap is cut off by the HTTP
# layer before the handler runs, so that one comes back as plain text, not JSON.

# 413 on the metrics endpoints — no "error" slug, just a detail
{"detail": "metrics payload too large (8MB cap)"}

Retrying correctly

Two rules cover almost everything. Never retry a rejection that will not change — 400, 401, 403 and 413 are all deterministic. And read the detail on a 429 before trusting the header, because the quota rejection is the one case where the header points at the wrong horizon.

retry.py
python
import time, random, httpx

API_KEY = "YOUR_API_KEY"

def send(ndjson_batch, *, max_attempts=5):
    """Retry ingest safely. Two things matter: never retry a 4xx that
    won't change (400/401/403/413), and don't trust Retry-After on the
    daily-quota 429 — that quota resets at midnight UTC, not in 60s."""
    for attempt in range(max_attempts):
        r = httpx.post(
            "https://ingest.getepok.dev/api/v1/ingest",
            headers={"X-API-Key": API_KEY},
            content=ndjson_batch,
            timeout=30,
        )
        if r.status_code < 400:
            return r
        if r.status_code in (400, 401, 403, 413):
            raise RuntimeError(f"not retryable: {r.status_code} {r.text}")
        if r.status_code == 429:
            detail = ""
            try:
                detail = r.json().get("detail", "")
            except ValueError:
                pass  # a proxy in front of us answered 429 in its own shape
            # The daily-quota rejection is the one that won't clear on a
            # short backoff. Detect it by the message and stop retrying.
            if "quota" in detail or "Daily volume" in detail:
                raise RuntimeError(f"daily quota: {detail}")
            delay = float(r.headers.get("retry-after", 2 ** attempt))
        else:  # 5xx
            delay = 2 ** attempt
        time.sleep(delay + random.uniform(0, 0.5))
    raise RuntimeError(f"ingest failed after {max_attempts} attempts")

Plan gates that look exactly like outages

Some features are switched off rather than absent, and a disabled feature answers with a 503 or 403 — the same codes an outage produces. These are deliberate gates. None of them clear on retry, none of them are worth an incident ticket, and each one names itself in the response so you can tell it apart from a real failure.

EndpointResponseMeans
POST /v1/metrics503 “Metrics ingest not enabled”The metrics data plane is off for your account. Every metrics write path returns this — OTLP, Influx line protocol and Prometheus remote write alike, so a collector will look entirely broken. Logs and traces are unaffected.
POST /v1/rum/replay503 “Session replay not enabled”Replay capture is off. The browser SDK keeps recording and keeps failing to upload; RUM events themselves still arrive, so you get sessions with no replay attached rather than an obvious error.
POST /api/v1/account/projects403 “Multi-project is not enabled”Separate projects (prod / staging under one bill) are off for your account. A second, distinct 403 — Project limit reached— means the feature is on but you are at your plan's project count.
AI endpoints403 “not available on your plan”The AI feature set is empty for your tier. This is what an expired trial sees on every AI surface.

The distinguishing test is simple: a 503 whose detail names a feature is a gate; a 503 that mentions authentication or gives no feature name is transient and worth retrying.

Day 15: what happens when the trial ends

The trial runs 14 days from signup. When the clock passes your expiry timestamp, an hourly sweep flips the account to a trial_expired state. This is worth reading before day 15, because the failure mode is loud and it surfaces inside your logging pipeline, not ours — shippers start erroring and it reads like an outage.

Log and trace ingest stops.

The expired state carries a daily byte allowance of 0, and zero is treated as blocked, not as unlimited. Every log and trace ingest request returns 429 with this exact string — greppable, if you are staring at a shipper log wondering what changed:

text
daily ingest quota is 0 for this tier (trial expired) — add a payment method to resume ingest

No amount of backoff clears this. Adding a payment method flips the account back to a paid tier; ingest resumes within about a minute, the time it takes the ingest plane's key cache to pick up the new tier. Data your shipper sent and had rejected during the blocked window is gone unless the shipper itself buffered and replays it, so if you are mid-trial and about to run an important test, convert first.

Everything you can read, you keep reading.

The expired state is deliberately not a lockout. It exists so you can still go and look at what you collected while deciding:

  • Search, dashboards and the explorer keep working — at a reduced 300 queries/minute instead of 1,200.
  • Detectors keep running on the data you already have. Alerts on existing streams continue to fire.
  • Retention stays at 14 days, the same as during the trial. Nothing is deleted on expiry — your data ages out on its normal schedule, so you have roughly two more weeks before the oldest of it goes.
  • You keep 3 users, 2 API keys, 2 live-tail sessions, 25 dashboards and 50 saved searches. Converting restores the paid ceilings.

AI switches off entirely. The expired state has an empty AI feature list and a credit budget of zero, so root-cause hypotheses, natural-language query, deep RCA and the rest return the plan-gate 403 above rather than degrading quietly.

AI credits: the headline number is not the one that stops you

AI features draw on a daily credit budget that resets each day. But the two most expensive features carry their own sub-caps inside that budget, and those bind long before the headline number does. Deep RCA is the feature most people want to try first, and on Trial and Team it stops at 40 while the overall budget still looks four-fifths full.

PlanDaily creditsDeep RCA sub-capPlaybook import sub-cap
Trial2004020
Team2004020
Growth50010050
CustomUnlimitedPer contractPer contract
Trial expired0 — AI off

The Trial budget is deliberately set to the Team budget, so what you evaluate is what you will get. Features without a listed sub-cap draw only against the headline number.

Next

  • Authentication — every accepted way to present a key, and how to tell a 401 from a 403.
  • API reference — the endpoints these limits apply to.
  • Kubernetes setup — where to filter volume at the source before it counts against your daily total.
  • Pricing — what each plan costs, alongside the limits above.