Errors & rate limits
Every error from /v1/* follows the same envelope, every
non-trivial route emits the same rate-limit headers, and the rules
for a sane client are short.
Error envelope
{
"ok": false,
"error": {
"code": "validation_failed", // snake_case machine code
"message": "Human-readable",
"errors": [ // optional, only on 400 validation_failed
{
"path": "attachments.0.size",
"code": "too_big",
"message": "Number must be less than or equal to 26214400"
}
],
"details": { /* optional, code-specific structured payload */ },
"retry_after_ms": 5000 // optional, set on 429 / 503
}
}The errors[] field-path detail mirrors the request body's
nesting — array indices are bare integers (attachments.0.size),
nested objects are dotted (payload.user.email). Each entry
carries:
path: dot-joined field path (""for root-level type mismatches).code: machine code (zod issue codes pass through verbatim:invalid_type,too_big,too_small,invalid_string, …).message: human-readable.
A client can render exactly the field that failed without parsing prose.
Standard error codes
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body, schema fail (carries errors[]) |
| 400 | invalid_token_location | Token in URL/query — must be header |
| 401 | invalid_token | Token unknown, malformed, revoked |
| 401 | invalid_signature | Webhook HMAC fail |
| 403 | permission_denied | Token lacks required scope |
| 403 | installation_revoked | Installation gone |
| 404 | session_not_found | Session deleted or never existed |
| 404 | interaction_not_found | Interaction expired or invalid |
| 409 | idempotency_conflict | Same key, different payload |
| 410 | installation_revoked | Mid-stream revoke |
| 410 | session_deleted | Mid-stream delete |
| 410 | interaction_expired | >30 min since update — followup window closed |
| 413 | payload_too_large | >1 MB JSON or >25 MB attachment |
| 422 | tool_not_declared | Tool name not in agent manifest |
| 429 | rate_limited | Per-token, per-installation, or per-IP limit |
| 451 | content_blocked | Backend content filter blocked output |
| 500 | internal_error | Backend bug |
| 502 | upstream_error | Bridge bus failure or similar |
| 503 | temporarily_unavailable | Backend overloaded — retry with retry_after_ms |
| 503 | agent_degraded | Agent in degraded state, snoozed |
The full list lives in the SAP RFC § 15.2.
Error handling rules
- 4xx: do not retry blindly. Inspect the code.
- 429 / 503: retry after
retry_after_mswith jitter. - Other 5xx: retry up to 3× with exponential backoff.
- 410: drop in-flight work and stop sending more — the thing you were writing to is gone.
Rate limit headers
Every response carries Discord-compatible rate-limit headers (no vendor prefix):
X-RateLimit-Limit: 30 # capacity (max burst)
X-RateLimit-Remaining: 27 # tokens left after this request
X-RateLimit-Reset: 1730345700 # epoch seconds until full
X-RateLimit-Reset-After: 0.300 # seconds (decimal) until full
X-RateLimit-Bucket: msg # bucket id for client-side scheduling
X-RateLimit-Scope: installation # 'installation' | 'agent'
On 429 the response additionally carries:
Retry-After: 1 # integer seconds (RFC 9110 § 10.2.3)
Buckets
| Bucket | Routes | Capacity | Refill/sec |
|---|---|---|---|
msg | sendMessage, sendMessageEnd | 30 | 10 |
delta | sendMessageDelta | 200 | 100 |
task | createTask, updateTask, finishTask | 60 | 30 |
approval | requestApproval | 10 | 2 |
memory | getMemory, setMemory (deferred) | 60 | 20 |
default | everything else | 30 | 10 |
Buckets are scoped per (scope, owner_id, bucket) — two
installations never share a bucket.
How to be a well-behaved client
- Read the headers, don't hit 429. Build a local
token-bucket scheduler keyed on
X-RateLimit-Bucket. Refill at the rate the table specifies. TreatX-RateLimit-Remainingas authoritative — if it's0, wait forX-RateLimit-Reset-Afterbefore sending the next request in that bucket. - Coalesce deltas. If your agent emits a token every 8 ms,
batch them into ~30 ms windows before POSTing. The user
can't see faster than that anyway, and the
deltabucket refills at 100/s. - Retry with idempotency. Every retry must reuse the same
idempotency_key(ortask_id/approval_id). See Idempotency & resume. - Backoff on 429 / 503. Use
Retry-After(orretry_after_ms) with ±25 % jitter. Never tight-loop. - Stop on 410. The session, interaction, or installation you were writing to is gone. Drop the in-flight work and tell the agent to wind down.
- Log
X-Request-IDon every error. It's the only thing that lets us correlate your bug report with our server logs. See Observability.