Documentation Index
Fetch the complete documentation index at: https://docs.exorde.io/llms.txt
Use this file to discover all available pages before exploring further.
The envelope
Every non-2xx response has the same shape:
{
"error": "<stable_code>",
"message": "<human readable, English, may change>",
"trace_id": "<16-hex>",
"...": "code-specific extras"
}
Three rules:
error is a stable enum. Match on it in code. We never change a code without a major version bump.
message is for humans. Show it in UIs. Wording may evolve; do not parse.
trace_id is the support handshake. Quote it in any ticket — we resolve in one round trip.
Every response (success and error) also returns the trace id in the X-Exorde-Trace-Id header.
Authentication errors
| Status | error | When | What to do |
|---|
| 401 | missing_api_key | X-API-Key header absent | Add the header |
| 401 | invalid_api_key | Unknown / rotated / revoked key | Mint via /v1/keys/trial or rotate |
| 401 | malformed_api_key | Header present but doesn’t match exd_<tier>_<token> | Check for stray whitespace, quotes |
| 403 | key_expired | Past expires_at | Upgrade or mint a new trial with a different email |
| 403 | key_revoked | Key was explicitly deleted via DELETE /v1/keys/current | Mint a new key |
Sample:
{
"error": "invalid_api_key",
"message": "API key not recognised",
"trace_id": "8b3a47ce91d04f17"
}
Authorisation errors
| Status | error | When | What to do |
|---|
| 403 | topic_denied | Key valid but not scoped to the requested topic | Upgrade to add the topic |
| 403 | upgrade_required | Endpoint requires a higher tier | Upgrade |
| 403 | feature_disabled | Feature gated off for your account (rare; bespoke contracts) | Contact support |
upgrade_required carries upgrade context so a UI can render a CTA without a second roundtrip:
{
"error": "upgrade_required",
"message": "This endpoint requires the 'see' tier",
"feature": "clusters",
"current_tier": "watch",
"required_tier": "see",
"upgrade": true,
"trace_id": "8b3a47ce91d04f17"
}
topic_denied echoes which topics you do have:
{
"error": "topic_denied",
"message": "Key not authorised for topic 'cyber'",
"requested_topic": "cyber",
"allowed_topics": ["global"],
"trace_id": "8b3a47ce91d04f17"
}
Resource errors
| Status | error | When | What to do |
|---|
| 404 | unknown_topic | Topic slug not in curated set | Use /v1/topics for the list |
| 404 | unknown_watchlist | Watchlist id not found or not owned by this client | Use GET /v1/watchlists |
| 404 | unknown_cluster | cluster_id not in the requested topic snapshot | Re-query /clusters |
| 404 | unknown_entity | Entity not in the requested topic snapshot | Re-query /entities |
| 404 | unknown_narrative | Narrative id not in topic snapshot | Re-query /narratives |
| 404 | report_not_found | No report yet for this topic | Try /reports/archive for older reports |
| 410 | snapshot_expired | Requested snapshot rolled out of retention | Use latest or a recent snapshot_id |
Validation errors
| Status | error | When |
|---|
| 422 | validation_error | Pydantic validation failed (missing field, wrong type, etc.) |
| 422 | invalid_email | Trial mint with a non-RFC-compliant email |
| 422 | invalid_term_type | Watchlist term.type not in keyword/phrase/entity/domain |
| 422 | empty_terms | Watchlist payload with terms: [] |
| 422 | watchlist_term_limit_reached | More terms than tier allows |
| 422 | invalid_base_topic | Watchlist base_topic not curated |
| 422 | invalid_signal_type | Subscription with unknown signal_type |
| 422 | invalid_webhook_url | Subscription delivery.url malformed or non-HTTPS |
| 409 | duplicate_watchlist_name | Watchlist name already used by this client |
Pydantic-style detail when validation_error:
{
"error": "validation_error",
"message": "Request body failed validation",
"fields": [
{ "loc": ["body", "terms", 0, "type"], "msg": "value is not a valid enum member" },
{ "loc": ["body", "name"], "msg": "field required" }
],
"trace_id": "8b3a47ce91d04f17"
}
Quota and rate-limit errors
| Status | error | When | What to do |
|---|
| 429 | rate_limited | RPM exceeded | Wait Retry-After, retry |
| 429 | monthly_quota_exceeded | Calls-per-month cap | Upgrade or wait for rollover |
| 429 | subscription_limit_reached | Webhook count cap | Delete an existing subscription or upgrade |
| 429 | watchlist_limit_reached | Watchlist count cap | Delete an existing watchlist or upgrade |
| 429 | trial_mint_throttled | Too many trial mints from this IP | Wait, then retry |
rate_limited:
{
"error": "rate_limited",
"message": "Rate limit exceeded — retry after 7s",
"retry_after_seconds": 7,
"limit_rpm": 30,
"trace_id": "8b3a47ce91d04f17"
}
watchlist_limit_reached:
{
"error": "watchlist_limit_reached",
"message": "Your tier allows up to 4 watchlists",
"current_tier": "see",
"limit": 4,
"current": 4,
"trace_id": "8b3a47ce91d04f17"
}
Full handling pattern: Rate limits.
Server-side errors
| Status | error | When | What to do |
|---|
| 500 | internal_error | Uncaught exception | Retry once; if it persists, email support with trace_id |
| 502 | upstream_error | ClickHouse / model service error | Retry with backoff |
| 503 | service_unavailable | Status endpoint reports degraded; or service draining for deploy | Retry; check /v1/status |
| 504 | upstream_timeout | Backend query exceeded the per-request budget | Retry; if persistent on a specific endpoint, email support |
service_unavailable:
{
"error": "service_unavailable",
"message": "Backing store is degraded — try again shortly",
"retry_after_seconds": 5,
"trace_id": "8b3a47ce91d04f17"
}
Subscription / webhook errors
| Status | error | When |
|---|
| 422 | invalid_subscription_scope | scope mismatched (e.g. kind: watchlist without an id) |
| 422 | unknown_subscription_type | type not in alert/digest/report |
| 409 | duplicate_subscription | Same scope+type+url already exists |
| 410 | webhook_dead | Endpoint returned 4xx/5xx N times in a row, auto-paused |
Webhook delivery (server-to-you, not request errors):
| Header | Meaning |
|---|
X-Exorde-Signature | sha256=<hex> HMAC of the body, signed with your subscription’s secret |
X-Exorde-Delivery-Id | Unique id for this delivery attempt; use to dedup |
X-Exorde-Subscription-Id | The subscription that produced this event |
X-Exorde-Event-Type | alert, digest, report |
Verify the signature server-side before trusting the payload.
Common patterns
Match on error, not status
A 403 can be one of topic_denied, key_expired, key_revoked, upgrade_required, feature_disabled — all need different UX. Don’t hardcode “403 = upgrade prompt”; read error.
def render_error(envelope: dict) -> str:
code = envelope["error"]
if code == "upgrade_required": return f"Upgrade to {envelope['required_tier']} for {envelope['feature']}"
if code == "topic_denied": return f"This topic isn't in your plan. You have: {envelope['allowed_topics']}"
if code == "key_expired": return "Your trial expired. Renew at [email protected]"
if code == "rate_limited": return f"Slow down — try again in {envelope['retry_after_seconds']}s"
return envelope["message"] # fallback
Always log trace_id
try:
r = httpx.get(url, headers=h)
r.raise_for_status()
except httpx.HTTPStatusError as e:
body = e.response.json()
log.error("intel api error",
extra={"status": e.response.status_code,
"code": body.get("error"),
"trace": body.get("trace_id")})
raise
Distinguish “retry” from “act”
| Retryable with no action | Need to act |
|---|
rate_limited, service_unavailable, upstream_error, upstream_timeout, internal_error (once) | everything else |
Encode this as a function:
RETRYABLE = {"rate_limited", "service_unavailable", "upstream_error",
"upstream_timeout", "internal_error"}
def is_retryable(envelope: dict) -> bool:
return envelope.get("error") in RETRYABLE
Support handshake
When you email [email protected], include:
- The full error envelope (especially
trace_id)
- The exact request URL and method
- Your
client_id (from /v1/keys/current, not the api_key itself)
- Approximate UTC timestamp
We resolve from trace_id alone in most cases. The other fields are belt-and-braces.
Last reviewed: 2026-05-19. API version 1.2.8.