Skip to content

Rate limits and quota

Every authenticated response tells you where you stand. Read the headers rather than counting requests yourself:

curl -sD - -o /dev/null https://api.trilocore.ai/api/v1/workspaces \
  -H "Authorization: Bearer $TRILOCORE_API_KEY" \
  | grep -i '^x-ratelimit\|^retry-after\|^x-request-id'
x-ratelimit-limit-requests: 600
x-ratelimit-remaining-requests: 597
x-ratelimit-reset-requests: 41
x-request-id: req_e345361146654a96898ff450881779d0

The headers

Header Meaning
x-ratelimit-limit-requests requests allowed in the current window
x-ratelimit-remaining-requests requests left in the current window
x-ratelimit-reset-requests seconds until the window resets
retry-after on a 429 only: seconds to wait. Never 0
x-request-id correlation id for this request; also in every error body

The three rate-limit headers appear on every response where the credential produced a limit verdict. x-ratelimit-reset-requests is a duration in seconds, not a timestamp.

These are x-ratelimit-*, not RateLimit-*

The naming follows the OpenAI convention, not the IETF RateLimit-* draft. There is no RateLimit-Limit, no RateLimit-Remaining, no RateLimit-Reset and no RateLimit-Policy header — and note the -requests suffix on all three, which is easy to drop when writing a client from memory. A library that auto-detects IETF headers will silently see no rate-limit information at all and will not back off.

Browser clients can read all five headers cross-origin: they are named in the API's CORS Access-Control-Expose-Headers, along with etag, location, link, x-resource-id and preference-applied.

Two different 429s

A 429 means one of two things, and they are not interchangeable. Branch on code:

code Window Waiting helps?
rate_limited a rolling per-minute window Yesretry-after is at most 60
quota_exhausted your plan's daily allowance Only until UTC midnight

rate_limited — you are going too fast

{
  "type": "https://docs.trilocore.ai/errors/rate-limited",
  "title": "Rate limit exceeded",
  "status": 429,
  "code": "rate_limited",
  "error": "rate_limited",
  "instance": "/api/v1/bevm/transactions",
  "request_id": "req_…"
}

The response carries retry-after set to the exact remainder of the window — the real number of seconds, not a flat 60 — plus x-ratelimit-remaining-requests: 0. Sleep for retry-after and continue.

Your per-minute allowance comes from your plan and is reported in x-ratelimit-limit-requests; do not hard-code it, because it changes when your plan does.

quota_exhausted — your daily allowance is spent

{
  "type": "https://docs.trilocore.ai/errors/quota-exhausted",
  "title": "Daily quota exhausted",
  "status": 429,
  "code": "quota_exhausted",
  "error": "quota_exhausted",
  "used": 5000,
  "limit": 5000,
  "plan": "team",
  "reset_at": "2026-08-16T00:00:00Z",
  "instance": "/api/v1/bevm/transactions",
  "request_id": "req_…"
}

Four extension members are merged into the problem document:

Field Meaning
used requests consumed today
limit the plan's daily allowance
plan the plan the limit came from
reset_at ISO-8601 instant when the allowance resets

Quota resets at UTC midnight, and retry-after is set to the number of seconds until then. That can be many hours, so this is not an error to sit and retry through: surface it, stop the worker, or upgrade the plan. Retrying in a loop will consume nothing but your own CPU — a rejected request is still rejected.

The unauthenticated endpoints

The four public verification endpoints are limited per client IP rather than per credential, at 60 requests per minute, with separate buckets for the report and attestation surfaces. They also cap request bodies more tightly than the authenticated API; an oversized body is rejected with 413 payload_too_large rather than a 429.

Because these limits are keyed on IP, everyone behind a shared egress address shares a bucket. If you are verifying attestations in bulk from a fleet, spread the work or use an authenticated credential.

Handling limits well

Retry 429 on retry-after, and back off exponentially with jitter on 500 and 502. Nothing else in the error catalogue should be retried unchanged.

import time

import httpx


def request_with_backoff(client: httpx.Client, method: str, url: str, **kw):
    for attempt in range(5):
        r = client.request(method, url, **kw)

        if r.status_code == 429:
            body = r.json()
            if (body.get("code") or body.get("error")) == "quota_exhausted":
                # Resets at UTC midnight — retrying is pointless. Fail loudly.
                raise RuntimeError(f"daily quota spent, resets {body.get('reset_at')}")
            time.sleep(int(r.headers.get("retry-after", "1")))
            continue

        if r.status_code in (500, 502):
            time.sleep(2**attempt)
            continue

        return r

    raise RuntimeError("giving up after 5 attempts")
const sleep = (s: number) => new Promise((r) => setTimeout(r, s * 1000));

async function requestWithBackoff(url: string, init: RequestInit) {
  for (let attempt = 0; attempt < 5; attempt++) {
    const r = await fetch(url, init);

    if (r.status === 429) {
      const body = await r.clone().json();
      if ((body.code ?? body.error) === "quota_exhausted") {
        // Resets at UTC midnight — retrying is pointless. Fail loudly.
        throw new Error(`daily quota spent, resets ${body.reset_at}`);
      }
      await sleep(Number(r.headers.get("retry-after") ?? 1));
      continue;
    }

    if (r.status === 500 || r.status === 502) {
      await sleep(2 ** attempt);
      continue;
    }

    return r;
  }
  throw new Error("giving up after 5 attempts");
}

Two habits keep you well clear of the limits:

  • Poll conditionally. Send If-None-Match with the ETag you were last given. A 304 still costs a request, but it is far cheaper for both sides than re-fetching an unchanged collection.
  • Reuse Idempotency-Key on retries. A retry that replays a stored response returns immediately, does no work, and cannot double-execute. See Idempotency.