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
| Surface | Anonymous |
|---|---|
| Sandbox dataset generation | 10 / 24h / IP |
| Decision Ledger demo | 10 / 15m / IP |
| Decision Ledger production | — |
| Ephemeral sandbox key mint | 3 / 24h / IP · 60 / min global |
| Commerce quote | Sandbox only |
| Commerce authorize | Sandbox only |
| Commerce execute | Sandbox only |
| SDK transaction capture | Sandbox only |
| Payment verify | 60 / min / IP |
| Certificate fetch | 60 / min / IP |
| Registry list | 60 / min / IP |
| Badge data | 300 / min / IP |
| Auth signup / login | 5 / 15m / IP |
| Agent identity mutations | — |
| Quota introspection | Unlimited (read-only) |
Response headers
# 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.
# 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:
- production certificate issuance (cert.v2 / v3 / v4 listed in the public registry)
- registry writes or marketplace publishing
- live commerce on
cdp_live_*keys - agent identity / DID registration under
/api/agents/:id/identity
- • Kill switch: set
AGENT_DID_PER_IP_MAX=0in 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 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-Afterwhen 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_codewhereretryable=no— see /agents/errors.