CertifiedData.io
Rate limits

Agent rate limits and backoff contract

Every CertifiedData agent endpoint exposes rate-limit state via response headers. Anonymous calls are capped per-IP; authenticated calls are capped per-key and per-plan.

Per-endpoint limits

SurfaceAnonymous
Sandbox dataset generation10 / 24h / IP
Decision Ledger demo10 / 15m / IP
Decision Ledger production
Ephemeral sandbox key mint3 / 24h / IP · 60 / min global
Commerce quoteSandbox only
Commerce authorizeSandbox only
Commerce executeSandbox only
SDK transaction captureSandbox only
Payment verify60 / min / IP
Certificate fetch60 / min / IP
Registry list60 / min / IP
Badge data300 / min / IP
Auth signup / login5 / 15m / IP
Agent identity mutations
Quota introspectionUnlimited (read-only)

Response headers

429 envelope — typical shape
# 429 response (headers also present on 200 responses)
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 30
RateLimit-Remaining: 0
RateLimit-Reset: 45                  # seconds until the window resets
Retry-After: 45                      # seconds
Content-Type: application/json

{
  "ok": false,
  "error": "Rate limit exceeded. Try again in a minute."
}

# Notes:
# - Standard draft headers are emitted (RateLimit-*), not X-RateLimit-*.
# - Some endpoints emit a plain text message body instead of JSON — handle
#   both by checking Content-Type before parsing.

Quota introspection

Read your current quota state without burning a request. Useful for self-throttling in CI suites and long-running agent loops. The endpoint itself is not rate-limited and does not count against any bucket it reports on.

GET /api/agents/me/quota
# anonymous lane
{
  "lane": "anonymous",
  "identity_key": "a1b2c3d4e5f6...",
  "schema_version": "certifieddata.quota.v1",
  "buckets": {
    "demo_window":              { "limit": 10, "remaining": 7, ... },
    "public_verify_window":     { "limit": 60, "remaining": 58, ... },
    "sandbox_key_daily":        { "limit": 3, "remaining": 3, ... },
    "sandbox_key_global_window":{ "limit": 60, "remaining": 60, ... }
  },
  "lanes_available": ["anonymous", "agent", "authenticated"]
}

# agent lane (with X-Agent-DID: did:cd:agent:ci-runner-prod-01)
{
  "lane": "agent",
  "identity_key": "f9e8d7c6b5a4...",       // hash of DID
  "did_status": "did_accepted",
  "agent_did_rotation": {
    "unique_dids_in_window": 1,
    "max": 10,
    "reset_at": "2026-04-25T19:00:00.000Z",
    "window_seconds": 3600
  },
  "buckets": {
    "demo_window":          { "limit": 60, "remaining": 60, ... },
    "public_verify_window": { "limit": 240, "remaining": 240, ... },
    "sandbox_key_daily":    { "limit": 30, "remaining": 30, ... },
    ...
  }
}

did_status values include did_accepted, did_rejected:<reason>, and did_downgraded:per_ip_rotation_cap — read it to debug "why am I in anonymous when I sent a DID?". schema_version is monotonic.

Agent (X-Agent-DID) lane

Send X-Agent-DID: did:cd:agent:<your-id> on requests to opt into a higher-throughput per-DID bucket. Useful when many agents share one egress IP (CI runners, shared NAT). Self-asserted, not authentication — sandbox capability scope is identical to the anonymous lane.

  • Format: did:cd:agent:[A-Za-z0-9._-:]{1,240} (≤256 total)
  • Rotation cap: 10 distinct DIDs / IP / hour. NEW DIDs over the cap are downgraded to the anonymous lane; previously-counted DIDs from the same IP keep agent-lane access.
  • Precedence: authenticated > agent > anonymous. The DID header is ignored on authenticated requests.
  • Never grants:
    1. production certificate issuance (cert.v2 / v3 / v4 listed in the public registry)
    2. registry writes or marketplace publishing
    3. live commerce on cdp_live_* keys
    4. agent identity / DID registration under /api/agents/:id/identity
    All four remain auth-gated by design.
  • Kill switch: set AGENT_DID_PER_IP_MAX=0 in env to disable the agent lane entirely without breaking anonymous quota. No code revert needed.
  • Storage: per-instance in-memory today. Migrate to Redis/Upstash (rate-limit-redis) before turning on more than one API instance — the DID rotation cap and global fleet cap assume a single shared counter.

Backoff strategy

TypeScript pattern
// TypeScript exponential backoff pattern
async function withBackoff<T>(fn: () => Promise<Response>, maxAttempts = 6): Promise<T> {
  for (let i = 0; i < maxAttempts; i++) {
    const res = await fn();
    if (res.ok) return res.json() as Promise<T>;

    if (res.status === 429 || res.status === 503) {
      const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** i;
      const jitter = Math.random() * 500;
      await new Promise((r) => setTimeout(r, retryAfter * 1000 + jitter));
      continue;
    }
    throw new Error(`non-retryable: ${res.status}`);
  }
  throw new Error("max attempts");
}
  • Always honor Retry-After when present.
  • Add jitter (±500ms) so fleets of agents don't synchronize retries.
  • Stop after ~6 attempts — longer retries indicate a structural problem, not transience.
  • Never retry on error_code where retryable=no — see /agents/errors.
Agent Rate Limits — Quotas and Backoff Headers | CertifiedData