Every non-2xx response shares the same typed envelope. Match on error in code, show message to humans, rely on extras only when present.
The envelope
{
"error": "upgrade_required",
"message": "This endpoint requires the 'see' tier",
"feature": "clusters",
"current_tier": "watch",
"required_tier": "see",
"upgrade": true
}
| Field | Always present | Notes |
|---|
error | Yes | Machine-readable code. Never localized. Safe to branch on. |
message | Yes | Human-readable English. Do not parse. |
| Extras | No | Context-specific: required_tier, feature, retry_after, limit, window_seconds, etc. Stable per code. |
Every endpoint returns this envelope on every failure. One handler, every case.
401 — Authentication
You did not provide a credential the server can verify.
| Code | Guidance |
|---|
missing_api_key | Provide your API key in the X-API-Key header. |
invalid_api_key | The key is unknown, revoked, or rotated. Mint a new one or use your active key. |
authentication_required | This endpoint requires authentication even when the underlying tier is public. |
Example:
{
"error": "invalid_api_key",
"message": "API key is invalid, revoked, or rotated"
}
403 — Authorization
Your credential is valid, but you cannot access this resource at this tier or scope.
| Code | Guidance |
|---|
upgrade_required | Your tier does not grant this feature. See required_tier and upgrade. |
topic_denied | Your key is not scoped to this topic. Request topic access or upgrade to Know. |
topic_not_subscribed | Your account is valid but not subscribed to this topic. |
key_expired | Your key is past expires_at. Renew or mint a new trial. |
Example of the upgrade wall — this is the shape to drive upsell UI:
{
"error": "upgrade_required",
"message": "This endpoint requires the 'see' tier",
"feature": "clusters",
"current_tier": "watch",
"required_tier": "see",
"upgrade": true
}
404 — Not found
The requested resource does not exist.
| Code | Guidance |
|---|
unknown_topic | The topic slug is not in the curated catalog. See /v1/topics. |
api_key_not_found | The key has no record in the backing store (revoked externally). |
watchlist_not_found | Watchlist id does not exist for your client_id. |
cluster_not_found | Cluster id does not exist within the current window. |
entity_not_found | Entity has no recent activity within the requested window. |
narrative_snapshot_not_found | Narrative snapshot id is unknown or outside retention. |
report_not_found | No report exists for the requested topic and period. |
subscription_not_found | Subscription id does not exist for your client_id. |
409 — Conflict
Your request conflicts with current state. The resource already exists, or the operation is not valid in the current state.
| Code | Guidance |
|---|
watchlist_name_conflict | A watchlist with this name already exists for your client_id. |
key_not_rotatable | Inactive or expired keys cannot be rotated. Mint a new key instead. |
subscription_name_conflict | A subscription with this target and webhook already exists. |
422 — Validation
Your request body or query parameters failed schema validation. The response includes a precise reason.
| Code | Guidance |
|---|
invalid_request_body | Request body failed schema validation. Inspect the response for the failing field. |
invalid_query_parameter | A query parameter failed validation. Inspect the response body. |
watchlist_invalid_term_type | Term type must be one of keyword, phrase, entity, domain. |
watchlist_invalid_term_value | Term value is empty, too long, or malformed for its type. |
watchlist_invalid_base_topic | base_topic must be one of the curated topics in /v1/topics. |
watchlist_term_limit_reached | Too many terms on this watchlist. See per-tier term limits in Tiers and Quotas. |
invalid_email | Email address is malformed. |
invalid_webhook_url | Webhook URL must be HTTPS and reachable. |
invalid_sensitivity_level | Must be one of low, medium, high. |
invalid_subscription_type | Must be one of topic, watchlist. |
Typical payload:
{
"error": "watchlist_invalid_term_type",
"message": "Term type must be one of: keyword, phrase, entity, domain",
"field": "terms.type",
"got": "url"
}
429 — Rate limiting and quotas
You are over a limit or quota. Quota codes are distinct from rate-limit codes — they carry different remediation.
Rate limits
| Code | Guidance |
|---|
rate_limited | You exceeded the per-minute rate limit. Back off using Retry-After. |
trial_issuance_rate_limited | Too many trial keys requested from this IP. Wait and retry. |
Rate-limited responses also carry:
| Header | Meaning |
|---|
Retry-After | Seconds to wait before retrying. |
X-RateLimit-Limit | Your tier’s per-minute ceiling. |
X-RateLimit-Remaining | Requests left in the current 60-second window. |
X-RateLimit-Reset | UNIX seconds until the window resets. |
See Rate limits for the full backoff pattern.
Quota caps
| Code | Guidance |
|---|
subscription_limit_reached | You hit your tier’s subscription cap. Delete one or upgrade. |
watchlist_limit_reached | You hit your tier’s watchlist cap. Delete one or upgrade. |
Example:
{
"error": "rate_limited",
"message": "Rate limit exceeded. Retry after 1 second.",
"retry_after": 1,
"limit": 30,
"window_seconds": 60
}
503 — Backend unavailable
A dependency is temporarily down. Retry with exponential backoff and jitter; never tight-loop.
| Code | Guidance |
|---|
auth_backend_unavailable | Key storage backend is temporarily unavailable. Retry with backoff. |
subscriptions_backend_unavailable | Subscription backend is temporarily unavailable. Retry with backoff. |
watchlists_backend_unavailable | Watchlist backend is temporarily unavailable. Retry with backoff. |
data_backend_unavailable | Analytics backend is temporarily unavailable. Retry with backoff. |
If you see any 503 persist beyond 30 seconds, check status.exorde.io for the affected stream.
Error handling, end to end
A single handler covers every case. Match on error first, then status. Examples in three languages.
import httpx, time, random
class ExordeError(Exception):
def __init__(self, status, code, message, payload):
super().__init__(f"{status} {code}: {message}")
self.status = status
self.code = code
self.payload = payload
def call(method, url, *, headers, **kw):
for attempt in range(5):
r = httpx.request(method, url, headers=headers, timeout=10, **kw)
if r.status_code == 429 or r.status_code == 503:
retry = int(r.headers.get("Retry-After", "1"))
time.sleep(retry + random.uniform(0, 0.5 * (2 ** attempt)))
continue
if r.status_code >= 400:
p = r.json()
raise ExordeError(r.status_code, p.get("error"), p.get("message"), p)
return r.json()
raise RuntimeError("exhausted retries")
class ExordeError extends Error {
constructor(status, code, message, payload) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
this.payload = payload;
}
}
async function call(method, url, { headers, body } = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const r = await fetch(url, { method, headers, body });
if (r.status === 429 || r.status === 503) {
const retry = Number(r.headers.get("Retry-After") ?? 1);
const jitter = Math.random() * 0.5 * Math.pow(2, attempt);
await new Promise(res => setTimeout(res, (retry + jitter) * 1000));
continue;
}
if (!r.ok) {
const p = await r.json();
throw new ExordeError(r.status, p.error, p.message, p);
}
return r.json();
}
throw new Error("exhausted retries");
}
function Invoke-Exorde($Method, $Uri, $Body = $null) {
for ($i = 0; $i -lt 5; $i++) {
try {
return Invoke-RestMethod `
-Method $Method `
-Uri $Uri `
-Headers @{ "X-API-Key" = $env:EXORDE_API_KEY } `
-ContentType "application/json" `
-Body $Body
} catch [System.Net.WebException] {
$resp = $_.Exception.Response
$status = [int]$resp.StatusCode
if ($status -eq 429 -or $status -eq 503) {
$retry = [int]$resp.Headers["Retry-After"]
Start-Sleep -Seconds ($retry + (Get-Random -Maximum 1))
continue
}
throw
}
}
throw "exhausted retries"
}
Branching rules — what to do per code class
- 401 → mint/rotate a new key and retry once. Never loop on 401.
- 403
upgrade_required → don’t retry. Surface the upsell using required_tier + feature.
- 403
topic_denied → don’t retry. Change topic or request access.
- 403
key_expired → don’t retry. Mint a new key.
- 404 → don’t retry. The resource does not exist in the current window.
- 409 → don’t retry. Read the state you conflicted with, then decide.
- 422 → don’t retry. Fix the request body or parameters.
- 429
rate_limited → retry with Retry-After + jitter.
- 429
*_limit_reached → don’t retry. Delete one or upgrade.
- 503 → retry with exponential backoff + jitter, capped at 30 s.
This page is maintained by hand and validated against the live API’s error surface and QA suite. Found a code in the wild that isn’t listed? Email [email protected] — it is a bug.