# Alerts
Source: https://docs.exorde.io/alerts
LLM-validated volume-spike signals with severity, spread, IOCs, and matched cluster context. The structured form of 'something just happened'.
An **alert** is a structured signal that conversation on a topic just spiked outside its normal pattern, validated by an LLM gate, with enough metadata to act on it without a human having to read the underlying posts. Alerts are one of the three pillars of the Intel API — alongside [trending](/trending) and [narrative](/narrative) — and the only one designed for **push-style consumption**: poll on Watch, subscribe to a webhook on See and Know.
## When an alert fires
The pipeline runs continuously. An alert is emitted when **all four** of the following hold:
1. A keyword's per-window volume crosses **5σ above its 14-day rolling baseline** on a topic or watchlist.
2. The spike is spread across **multiple domains and languages** — single-domain bursts are filtered as noise.
3. An **LLM gate** classifies the spike as a real, describable event (not a recurring meme, scheduled show, or platform artifact).
4. The signal hasn't already been emitted in the current deduplication window.
Low-volume topics like `cyber` or `disinfo` may produce **zero alerts in a 24-hour window**. That is by design: alerts are intentionally rare. Use [`/v1/topics/{t}/volume`](/topics-and-watchlists#analytics-endpoints-on-curated-topics) for raw activity instead.
The default `hours=168` (7 days) on `/v1/topics/{t}/alerts` exists for exactly this reason — it gives quiet topics a useful window without forcing every caller to remember the parameter. Tune down to `hours=24` for high-volume topics like `global`.
## The alert envelope
Same JSON shape on every endpoint that returns alerts: `/v1/topics/{t}/alerts`, `/v1/watchlists/{id}/alerts`, and webhook deliveries.
```json theme={null}
{
"alert_id": "c80fcfed-6818-44ed-a0b9-0eda91d1401c",
"detected_at": "2026-05-18T04:00:30.148Z",
"topic": "cyber",
"signal_type": "volume_spike",
"source": "aggregator",
"keyword": "dark web",
"confidence": 0.72,
"severity": {
"deviation_sigma": 6.67,
"current_value": 24.0,
"baseline_value": 3.29
},
"spread": {
"domain_count": 14,
"language_count": 8
},
"llm_validated": true,
"description": "Multiple credible data breach disclosures (Turkish breach, FoxIT/Foxit software, gaming accounts) surfacing on dark web with fact-checker verification signals genuine cybersecurity incidents being reported and discussed across platforms.",
"sample_posts": [
{
"preview": "Turkish operator breach reportedly exposed via dark-web listing — fact-check pending...",
"domain": "x.com",
"language": "en",
"captured_at": "2026-05-18T03:42:11Z"
}
],
"iocs": {
"urls": [],
"ips": [],
"domains": [],
"hashes": { "md5": [], "sha1": [], "sha256": [] },
"cves": [],
"crypto_wallets": [],
"emails": []
},
"matched_cluster": {
"cluster_id": 258,
"cluster_title": "Dark-web breach disclosures, May 2026",
"narrative_context": "Cluster tracking weekly cadence of breach announcements with fact-checker overlay."
}
}
```
## Field guide
### Identity
| Field | Type | Purpose |
| ------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `alert_id` | UUID | Stable, globally unique. **Use for dedup across polls and across webhook redeliveries.** |
| `detected_at` | ISO-8601 UTC | Wall-clock moment the spike crossed threshold. Not when you fetched it. |
| `topic` | string | Curated topic slug (e.g. `cyber`). Absent on watchlist alerts; `watchlist_id` is present instead. |
| `source` | enum | Pipeline stage that emitted the alert: `aggregator`, `cluster`, `entity`. `aggregator` covers volume spikes; the others are content-driven. |
### Signal type
`signal_type` is the discriminator. Today's stable values:
| `signal_type` | Meaning | Carries IOCs? |
| ------------------- | ---------------------------------------------------------- | --------------------------------------- |
| `volume_spike` | Keyword volume on the topic exceeded 5σ baseline | Sometimes (extracted from sample posts) |
| `keyword_spike` | Synonym for `volume_spike`, retained for legacy clients | Sometimes |
| `coordination` | Cross-domain synchronised posting pattern | Rare |
| `sentiment_shift` | Sharp sentiment polarity shift on an established narrative | No |
| `anomaly` | Statistical outlier that doesn't fit other categories | No |
| `cluster_emergence` | A new conversation cluster just crystallised | Often |
| `cluster_death` | An active cluster collapsed below activity threshold | No |
Match on `signal_type` to route alerts to the right consumer (SOC vs. brand vs. newsroom).
### Severity
```json theme={null}
"severity": {
"deviation_sigma": 6.67,
"current_value": 24.0,
"baseline_value": 3.29
}
```
| Field | Meaning |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `deviation_sigma` | How many standard deviations above the 14-day baseline. **5.0 is the floor**; anything higher is unusually loud. 6.67 (the example above) is "drop everything and look." |
| `current_value` | Raw volume in the detection window |
| `baseline_value` | Mean volume over the trailing 14 days for the same window length |
The math, in plain terms: `deviation_sigma = (current_value − baseline_value) / σ_14d`, and the alert is only emitted when `deviation_sigma ≥ 5`.
### Spread
The virality footprint. Single-domain spikes — even loud ones — are filtered out. An alert with `domain_count: 14, language_count: 8` is a story crossing platforms and language communities, not one viral tweet.
| Field | Meaning |
| ---------------- | ---------------------------------------------------------- |
| `domain_count` | Distinct source domains carrying the keyword in the window |
| `language_count` | Distinct languages (ISO 639-1 codes) of those posts |
A common disinfo filter is `domain_count >= 5 AND language_count >= 3` (see [Use cases recipe 4](/use-cases#4-disinformation-early-warning)).
### Confidence and LLM validation
| Field | Meaning |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `confidence` | Float 0.0–1.0. The model's estimate that this signal is a real event vs. noise. Use as a UI sort key. |
| `llm_validated` | Boolean. The LLM gate either confirmed the spike represents a describable real-world event, or didn't. **Filter to `true` for high-stakes downstream consumers.** |
| `description` | Human-readable, English, 1–3 sentences. Editorial-grade. Drop straight into a Slack alert without rewriting. |
### Evidence
```json theme={null}
"sample_posts": [
{ "preview": "...", "domain": "x.com", "language": "en", "captured_at": "..." }
]
```
3–5 representative posts. Truncated to \~160 chars; for full content fetch [`/v1/topics/{t}/posts`](/topics-and-watchlists#analytics-endpoints-on-curated-topics) (See tier and above).
### IOCs
The IOC extractor runs on every alert with text content, including `volume_spike` types. Always present, often empty.
```json theme={null}
"iocs": {
"urls": [],
"ips": [],
"domains": [],
"hashes": { "md5": [], "sha1": [], "sha256": [] },
"cves": [],
"crypto_wallets": [],
"emails": []
}
```
The shape is **always the full schema, even when empty**. Code can iterate keys safely without `if "cves" in iocs` checks.
### Matched cluster
If the spike falls inside an existing conversation cluster, the alert links to it:
```json theme={null}
"matched_cluster": {
"cluster_id": 258,
"cluster_title": "Dark-web breach disclosures, May 2026",
"narrative_context": "Cluster tracking weekly cadence of breach announcements..."
}
```
Drill down with `GET /v1/topics/{t}/clusters/{cluster_id}` (See tier) for the full cluster: top entities, top domains, time-series, full evidence post list. `matched_cluster` is `null` when the spike doesn't fit any active cluster — usually meaning it's a brand-new story.
## Endpoints that return alerts
| Endpoint | Tier | Returns |
| --------------------------------------------- | ------ | --------------------------------------- |
| `GET /v1/topics/{topic}/alerts` | Watch+ | Alerts for a curated topic |
| `GET /v1/watchlists/{id}/alerts` | See+ | Alerts scoped to your watchlist's terms |
| `POST /v1/subscriptions` (with `type: alert`) | See+ | Webhook push delivery, same envelope |
Query parameters on the polling endpoints:
| Param | Default | Watch cap | See cap | Know cap |
| --------------- | ------- | --------- | ------- | -------- |
| `hours` | 168 | 24 | 72 | 168 |
| `limit` | 50 | 50 | 100 | 200 |
| `signal_type` | (any) | — | — | — |
| `min_sigma` | 5.0 | — | — | — |
| `llm_validated` | (any) | — | — | — |
Request a `hours` value above your tier cap and the response is silently clamped — the JSON includes the effective window in `query_window`.
## Polling pattern (Watch and See)
```python theme={null}
import os, time, httpx
from datetime import datetime, timezone
BASE = "https://intel-v1.exorde.io"
HEADERS = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
SEEN: set[str] = set()
def poll(topic: str, hours: int = 24) -> list[dict]:
r = httpx.get(
f"{BASE}/v1/topics/{topic}/alerts",
params={"hours": hours, "limit": 50, "llm_validated": True},
headers=HEADERS,
timeout=10,
)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
return []
r.raise_for_status()
fresh = [a for a in r.json()["alerts"] if a["alert_id"] not in SEEN]
SEEN.update(a["alert_id"] for a in fresh)
return fresh
while True:
for a in poll("global", hours=24):
sev = a["severity"]
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
print(f"[{ts}] {a['keyword']:<25} σ={sev['deviation_sigma']:.2f} "
f"({a['spread']['domain_count']}d × {a['spread']['language_count']}l)")
time.sleep(60)
```
**Cadence guidance:**
| Tier | Cadence | Daily call cost |
| ----------------- | --------- | --------------- |
| Watch | every 60s | \~1,440 / day |
| See (poll) | every 10s | \~8,640 / day |
| See / Know (push) | webhook | 0 RPM |
Below 5-second freshness, **switch to webhooks**. See [Rate limits](/rate-limits#recommended-polling-cadence).
## Webhook delivery (See and Know)
```bash theme={null}
curl -X POST https://intel-v1.exorde.io/v1/subscriptions \
-H "X-API-Key: $EXORDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "alert",
"scope": { "kind": "topic", "topic": "cyber" },
"delivery": {
"kind": "webhook",
"url": "https://your.app/exorde-webhook",
"secret": "whsec_..."
},
"filters": {
"min_sigma": 6.0,
"llm_validated": true
}
}'
```
Each delivery POSTs the alert envelope (above) to your URL, with these headers:
| Header | Meaning |
| -------------------------- | ----------------------------------------------------------------------- |
| `X-Exorde-Signature` | `sha256=` HMAC of the body, signed with your subscription's secret |
| `X-Exorde-Delivery-Id` | Unique per delivery attempt; **use to dedup retries** |
| `X-Exorde-Subscription-Id` | The subscription that produced this event |
| `X-Exorde-Event-Type` | Always `alert` for this subscription type |
Verify the signature server-side **before** trusting the payload:
```python theme={null}
import hmac, hashlib
def verify(body: bytes, signature_header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)
```
Webhooks that return non-2xx **N times in a row** auto-pause and emit `webhook_dead`. Re-enable from `PATCH /v1/subscriptions/{id}` once your endpoint is healthy. See [Errors → Subscription / webhook errors](/errors#subscription-webhook-errors).
## Filtering patterns
**Newsroom — only loud, validated, multi-platform stories:**
```python theme={null}
high_signal = [
a for a in alerts
if a["llm_validated"]
and a["severity"]["deviation_sigma"] >= 6.0
and a["spread"]["domain_count"] >= 8
]
```
**Threat-intel — only alerts carrying actionable IOCs:**
```python theme={null}
def has_iocs(a: dict) -> bool:
i = a["iocs"]
return bool(
i["urls"] or i["ips"] or i["domains"]
or i["cves"] or i["crypto_wallets"]
or any(i["hashes"].values())
)
actionable = [a for a in alerts if has_iocs(a)]
```
**Disinfo — coordinated multi-language pushes only:**
```python theme={null}
suspicious = [
a for a in alerts
if a["llm_validated"]
and a["spread"]["language_count"] >= 3
and a["spread"]["domain_count"] >= 5
and a["confidence"] >= 0.7
]
```
## Idempotency and dedup
* **Across polls:** `alert_id` is stable. Keep a `set` of seen IDs (or a Redis `SADD` with TTL) and skip duplicates.
* **Across webhook retries:** Use `X-Exorde-Delivery-Id` as the dedup key — same `alert_id` may be redelivered if your endpoint 5xx'd.
* **Across rotations:** Alerts persist through key rotation. The `alert_id` doesn't reset.
## Operational guidance
* **Don't trust `description` for routing** — it's prose. Route on `signal_type`, `topic`, `severity.deviation_sigma`, `iocs` presence.
* **Always pass `llm_validated: true`** in production filters unless you're explicitly hunting noise.
* **Persist `alert_id` for at least 7 days** — the maximum dedup window. Shorter and you'll re-page the on-call.
* **Show the `trace_id`** (response header `X-Exorde-Trace-Id`) on any UI that surfaces an alert. It's the support handshake.
* **`matched_cluster: null` is a feature**, not missing data — it tells you "this is brand new, not part of an ongoing story."
* **Alerts count against RPM but not monthly quota** when delivered via webhook. Push is the right architecture above 5-second cadence.
## What's not an alert
For clarity:
| You want | Use this |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------ |
| "What are the top terms right now" | [`/v1/topics/{t}/trending`](/trending) |
| "What is the dominant storyline" | [`/v1/topics/{t}/narrative`](/narrative) |
| "Show me posts mentioning X" | [`/v1/topics/{t}/search`](/topics-and-watchlists#analytics-endpoints-on-curated-topics) (See+) |
| "Track my brand specifically" | [Watchlists](/topics-and-watchlists#custom-watchlists) (See+) |
| "Editorial weekly summary" | [`/v1/topics/{t}/reports/latest`](/topics-and-watchlists#analytics-endpoints-on-curated-topics) (Know) |
Alerts are the **push-shaped, machine-routable** view of the data. Everything else is pull-shaped and human-shaped.
Last reviewed: 2026-05-19. API version 1.2.8.
# Authentication
Source: https://docs.exorde.io/authentication
X-API-Key header, trial minting, rotation, revocation, and the typed error envelope. Everything you need to manage credentials in production.
## The header
Every authenticated request carries your key in the `X-API-Key` header.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/topics/global/trending \
-H "X-API-Key: exd_trial_QLocUNNcjQ7TXxTgJZ2DWww4QxjlLBgc"
```
No cookies, no OAuth, no signed requests. Keys are secrets — treat them like passwords.
**Never commit a key to git, embed it in a public SPA bundle, or email it in plaintext.** If a key is exposed, rotate it immediately (see below) — rotation is atomic and the old key dies the same instant the new one is born.
## Key tiers and prefixes
Each key carries a prefix that hints at its tier. The prefix is **cosmetic** — the real tier is stored server-side and returned by `GET /v1/keys/current`.
| Prefix | Tier | Typical source |
| ------------ | ----- | ---------------------------------------------------------------- |
| `exd_trial_` | Watch | `POST /v1/keys/trial` (free, 7 days) |
| `exd_watch_` | Watch | Paid Watch subscription |
| `exd_see_` | See | Paid See subscription |
| `exd_know_` | Know | Paid Know subscription |
| `exd_test_` | Test | Internal QA, integrator plumbing tests (not issued to customers) |
Test-mode keys (`exd_test_*`) bypass the database and serve **fixture data** for deterministic plumbing tests. Production data requires a real key. See [Test mode](#test-mode) below.
## Minting a trial key
Public endpoint, IP-rate-limited, **idempotent per email** within the key's active window. Call it twice with the same email and you get the same key back.
```bash curl theme={null}
curl -X POST https://intel-v1.exorde.io/v1/keys/trial \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}'
```
```python Python theme={null}
import httpx
r = httpx.post(
"https://intel-v1.exorde.io/v1/keys/trial",
json={"email": "you@example.com"},
)
r.raise_for_status()
data = r.json()
print(data["api_key"], "reused:", data["reused"])
```
A successful response (HTTP 201 first time, HTTP 200 if replayed):
```json theme={null}
{
"api_key": "exd_trial_QLocUNNcjQ7TXxTgJZ2DWww4QxjlLBgc",
"client_id": "trial_3c91be7f",
"tier": "watch",
"topics": ["global"],
"webhook_limit": 0,
"rate_limit_rpm": 30,
"monthly_call_quota": 5000,
"active": true,
"created_at": "2026-05-19T13:00:00Z",
"expires_at": "2026-05-26T13:00:00Z",
"reused": false
}
```
If you call this endpoint again with the same email **while a valid key still exists**, you get the same key back with `reused: true` and HTTP 200. No duplicate keys, no silent reissuance.
If the previous key for that email has expired or been revoked, a fresh key is minted (HTTP 201, `reused: false`).
## Inspecting the current key
Use this at runtime to discover what your key can do — tier, topics, expiry, rate limit, webhook quota.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/keys/current \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"api_key": "exd_trial_QLocUNNcjQ7TXxTgJZ2DWww4QxjlLBgc",
"client_id": "trial_3c91be7f",
"tier": "watch",
"topics": ["global"],
"webhook_limit": 0,
"rate_limit_rpm": 30,
"monthly_call_quota": 5000,
"active": true,
"expires_at": "2026-05-26T13:00:00Z"
}
```
Same shape as the trial response, without `reused`. Build tier-aware UIs from this — read once at app start, refresh on 401/403.
## Rotating a key
Rotation issues a new key with the **same tier, topics, limits, and expiry**. The old key is deactivated atomically — switch your clients to the new key immediately.
```bash theme={null}
curl -X POST https://intel-v1.exorde.io/v1/keys/rotate \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"old_api_key": "exd_trial_QLocUNNcjQ7TXxTgJZ2DWww4QxjlLBgc",
"new_api_key": "exd_trial_r3qiDtHjRNXp8wEYCPRH8L9Wv2nCNkqA",
"client_id": "trial_3c91be7f",
"tier": "watch",
"topics": ["global"],
"webhook_limit": 0,
"rate_limit_rpm": 30,
"expires_at": "2026-05-26T13:00:00Z"
}
```
**Expiry is not extended** by rotation. Rotation is for credential hygiene, not lifetime extension. Replay protection: every subsequent call with the **old** key returns `401 invalid_api_key`.
## Revoking a key
Permanent. Idempotent. Once revoked, the key cannot be reactivated — a future POST to `/v1/keys/trial` with the same email will mint a brand-new key.
```bash theme={null}
curl -X DELETE https://intel-v1.exorde.io/v1/keys/current \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"api_key": "exd_trial_r3qiDtHjRNXp8wEYCPRH8L9Wv2nCNkqA",
"revoked": true,
"already_inactive": false,
"revoked_at": "2026-05-19T13:42:11Z"
}
```
A second call with the same key returns `already_inactive: true` and HTTP 200 (idempotent, not an error).
## Who am I
`GET /v1/me` returns the caller's identity and **full entitlements** — tier, topics, rate limit, webhook + watchlist quotas, monthly usage. Ideal for building a tier-aware dashboard header or settings page.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/me \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"client_id": "trial_3c91be7f",
"tier": "watch",
"topics": ["global"],
"limits": {
"rate_limit_rpm": 30,
"monthly_call_quota": 5000,
"webhook_limit": 0,
"watchlist_limit": 0,
"watchlist_term_limit": 0
},
"usage": {
"api_calls_this_month": 127,
"period": "2026-05",
"webhooks_active": 0,
"watchlists_active": 0
},
"expires_at": "2026-05-26T13:00:00Z",
"trace_id": "8b3a47ce91d04f17"
}
```
## The typed error envelope
Every non-2xx response follows the same shape. Match on the `error` enum, show `message` to users, log `trace_id` for support.
```json theme={null}
{
"error": "upgrade_required",
"message": "This endpoint requires the 'see' tier",
"feature": "clusters",
"current_tier": "watch",
"required_tier": "see",
"upgrade": true,
"trace_id": "8b3a47ce91d04f17"
}
```
The four authentication-specific errors:
| Status | `error` | When | Action |
| ------ | ----------------- | ----------------------------------------------- | --------------------------- |
| 401 | `missing_api_key` | Header not sent | Add `X-API-Key` |
| 401 | `invalid_api_key` | Unknown / rotated / revoked key | Mint or rotate |
| 403 | `key_expired` | Past `expires_at` | Upgrade or mint a new trial |
| 403 | `topic_denied` | Key valid but not scoped to the requested topic | Upgrade or change topic |
Full list across all routers: [Errors](/errors).
**Trace IDs are 16-hex strings** present on every response (success or error) in the `X-Exorde-Trace-Id` header and inside the JSON envelope on errors. Quote the trace\_id when you email support — we resolve in one round trip.
## Test mode
Keys with the `exd_test_` prefix bypass the database, return a synthetic `know`-tier shape, and serve **fixture data** on every analytics endpoint. Use them to wire integrations (HTTP layer, JSON parsing, error handling) without burning real quota or polluting analytics.
```bash theme={null}
# Always returns the same fixture alert envelope, with detected_at rotated to "now"
curl "https://intel-v1.exorde.io/v1/topics/cyber/alerts?hours=24" \
-H "X-API-Key: exd_test_smoke"
```
How to tell apart a fixture alert from a live alert:
| Field | Test fixture | Live data |
| ------------------------ | ---------------------------------------------------- | -------------------------------- |
| `alert_id` | starts with `sig_test_` | random UUID, no `_test_` infix |
| `detected_at` | rotated to "now-ish" | actual detection time |
| `keyword` | always `iran` for cyber, `bitcoin` for finance, etc. | whatever is actually spiking |
| `sample_posts[].preview` | templated string | first \~160 chars of a real post |
Test keys are not issued to customers. They exist for our internal QA suite and for integrator partners who request a deterministic fixture path during onboarding.
## Operational guidance
* **Store keys server-side only.** Never in a public SPA bundle, git history, or a client-side env var shipped to users.
* **Rotate on suspicion of leak.** Rotation is free, atomic, and preserves all tier/topic/limit settings.
* **Use `/v1/me` at startup** to detect tier changes (upgrades, downgrades, expiries) without polling the billing system.
* **Handle 401 as "get a new key"** and **403 as "ask the user to upgrade or change topic"** — the codes are distinct for a reason.
* **Always log `trace_id`** alongside any user-facing error message. It is the single most useful piece of evidence in a support ticket.
* **Set a watchdog on `expires_at`.** Trial keys expire silently after 7 days; surface a "renew/upgrade" prompt when within 24h of expiry.
## Lifecycle example: full mint → rotate → revoke
```python theme={null}
import httpx, time
BASE = "https://intel-v1.exorde.io"
EMAIL = "ops@example.com"
# 1. Mint
r = httpx.post(f"{BASE}/v1/keys/trial", json={"email": EMAIL}).raise_for_status()
key = r.json()["api_key"]
print("minted:", key)
# 2. Use it
me = httpx.get(f"{BASE}/v1/me", headers={"X-API-Key": key}).json()
print("tier:", me["tier"], "topics:", me["topics"])
# 3. Rotate (suspected leak)
r = httpx.post(f"{BASE}/v1/keys/rotate", headers={"X-API-Key": key}).json()
new_key = r["new_api_key"]
print("rotated:", key, "->", new_key)
# Old key now dead
assert httpx.get(f"{BASE}/v1/me", headers={"X-API-Key": key}).status_code == 401
# 4. Revoke (when done)
r = httpx.delete(f"{BASE}/v1/keys/current", headers={"X-API-Key": new_key}).json()
assert r["revoked"] is True
print("revoked:", new_key)
```
Every step in this lifecycle is exercised by our QA suite (205 scenarios, currently 100% PASS). See [Changelog](/changelog) for release-by-release detail.
Last reviewed: 2026-05-19. API version 1.2.8.
# Changelog
Source: https://docs.exorde.io/changelog
Release-by-release notes. What changed, what broke, what to know.
We follow semantic versioning on the API contract: **MAJOR** = breaking, **MINOR** = additive, **PATCH** = fixes & internal.
The current production version is reported by `GET /v1/version`.
***
## v1.2.8 — 2026-05-19
**Polish & defaults release. Customer-facing improvements without contract changes.**
### Changed
* **Default `hours` parameter on `/v1/topics/{t}/alerts` is now 168 (7 days).** Previously 24, which returned empty arrays on quiet days for low-volume topics like `cyber` and `disinfo`. Existing integrations passing `hours=` explicitly are unaffected.
* `/v1/keys/current` for `exd_test_*` keys now returns 200 with the synthetic test envelope instead of 503.
* `/v1/status` freshness probes refactored — typical response time dropped from 5,000 ms to under 50 ms by removing a non-leading sort-key WHERE clause that forced full part scans on `volume_signals` and `topic_narratives`.
### Fixed
* Trial mint email regex (`_EMAIL_RE`) accepts the full RFC 5322 character set including `+`, `-`, `_`, `.` in the local part. Previously rejected addresses like `qa+smoke@exorde.io`.
* Test-mode keys correctly short-circuit `get_current_client()` instead of attempting a database lookup that returned 503.
### Internal (no API impact)
* QA suite at 205 / 205 PASS.
* 30s response cache on `/v1/status` now has a useful hit rate (was bypassed by the slow probe path).
***
## v1.2.7 — 2026-05-15
**Auth middleware rewrite & error envelope unification.**
### Added
* **Typed error envelopes everywhere.** Every non-2xx response now carries `error` (stable enum), `message` (human), `trace_id` (16-hex). Match on `error` in code; show `message` to users; quote `trace_id` in support tickets.
* **`X-Exorde-Trace-Id` header** on every response, success and error.
* **Ghost-topic gate.** Topic slugs we know about but don't expose (legacy or restricted) now return `404 unknown_topic` instead of leaking through with empty payloads.
* **IOC extractor** now covers `volume_spike` alerts in addition to content alerts. Extracts URLs, IPs, domains, MD5/SHA1/SHA256 hashes, CVEs, crypto wallets, emails.
* **`GET /v1/me`** returns full tier entitlements: tier, topics, rate limit, all quotas, current usage. Build tier-aware UIs without polling billing.
### Changed
* `auth.py` rewritten end to end. Faster topic gate (\~1.5× lower p50 on auth path), `Retry-After` honored on watch-tier mint replays, expiry check now happens after tier gating so an expired See key returns `key_expired` not `upgrade_required`.
### Fixed
* Trial mint replay returns the same key with `reused: true` and `Retry-After` set, instead of issuing duplicate trials within a window.
***
## v1.2.6 — 2026-05-08
### Added
* **Watchlist analytics surface complete.** `/v1/watchlists/{id}/{trending|volume|volume/keywords|narrative|entities|platforms|posts|alerts|clusters}` all live. Same JSON shapes as the topic equivalents.
* **Webhook subscriptions** on See/Know tiers — push delivery for alerts and digests, with HMAC-SHA256 signed payloads (`X-Exorde-Signature`).
* **Subscription quota envelope** — `429 subscription_limit_reached` with current/limit fields.
### Changed
* Watchlist term limits enforced at create + patch time: `422 watchlist_term_limit_reached`.
***
## v1.2.5 — 2026-04-29
### Added
* **`GET /v1/topics/{t}/alerts`** stable. Volume-spike alerts with severity (`deviation_sigma`, `current_value`, `baseline_value`), spread (`domain_count`, `language_count`), `llm_validated` flag, `matched_cluster` link.
* **Test-mode key prefix** `exd_test_*` — synthetic `know`-tier path with deterministic fixtures for integrator plumbing tests.
### Fixed
* `/v1/topics/{t}/clusters/{id}/posts` no longer 500s when the cluster has zero evidence posts (returns `404 unknown_cluster` or empty `posts: []` depending on cause).
***
## v1.2.0 — 2026-04-15
**Major release: tiers, watchlists, and the four curated topics.**
### Added
* **Three-tier model** — Watch / See / Know. See [Tiers and quotas](/tiers).
* **Four curated topics** — `global`, `cyber`, `finance`, `disinfo`. Topic scope per key.
* **Custom watchlists** with four term types (`keyword`, `phrase`, `entity`, `domain`).
* **Editorial reports** (`/v1/topics/{t}/reports/latest`, `/archive`) — Know tier.
* **Trial mint endpoint** `POST /v1/keys/trial` with email idempotency.
### Changed
* Auth header standardised on `X-API-Key`. Legacy `Authorization: Bearer` removed.
***
## v1.1 — 2026-03-12
### Added
* Cluster analytics, entity leaderboards, full-text search.
* Snapshot model: every analytics response includes a `snapshot_id` you can pin to.
***
## v1.0 — 2026-02-01
Public launch. Trending, volume, narrative, posts. Single-tier, single-topic.
***
## Versioning policy
* **MAJOR**: contract-breaking. Field removed, type changed, error code semantics changed.
* **MINOR**: additive. New endpoints, new fields, new error codes.
* **PATCH**: fixes and internal improvements.
We never silently change a stable `error` code. If a code is renamed, the old code remains as an alias for at least one MAJOR version.
## Subscribing
* Email digest of releases — opt in via [intel@exorde.io](mailto:intel@exorde.io).
* Webhook for subscribers — Know-tier customers can add a `release_note` subscription type.
Last reviewed: 2026-05-19.
# Errors
Source: https://docs.exorde.io/errors
The typed error envelope. Every error code, what triggers it, what to do, what status it carries. Trace IDs on every request.
## The envelope
Every non-2xx response has the same shape:
```json theme={null}
{
"error": "",
"message": "",
"trace_id": "<16-hex>",
"...": "code-specific extras"
}
```
Three rules:
1. **`error` is a stable enum.** Match on it in code. We never change a code without a major version bump.
2. **`message` is for humans.** Show it in UIs. Wording may evolve; do not parse.
3. **`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](/quickstart) or rotate |
| 401 | `malformed_api_key` | Header present but doesn't match `exd__` | 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:
```json theme={null}
{
"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:
```json theme={null}
{
"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:
```json theme={null}
{
"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](/topics-and-watchlists) 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`:
```json theme={null}
{
"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`:
```json theme={null}
{
"error": "rate_limited",
"message": "Rate limit exceeded — retry after 7s",
"retry_after_seconds": 7,
"limit_rpm": 30,
"trace_id": "8b3a47ce91d04f17"
}
```
`watchlist_limit_reached`:
```json theme={null}
{
"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](/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](https://intel-v1.exorde.io/v1/status) |
| 504 | `upstream_timeout` | Backend query exceeded the per-request budget | Retry; if persistent on a specific endpoint, email support |
`service_unavailable`:
```json theme={null}
{
"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=` 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`.
```python theme={null}
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 intel@exorde.io"
if code == "rate_limited": return f"Slow down — try again in {envelope['retry_after_seconds']}s"
return envelope["message"] # fallback
```
### Always log `trace_id`
```python theme={null}
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:
```python theme={null}
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 [intel@exorde.io](mailto:intel@exorde.io), include:
1. The full error envelope (especially `trace_id`)
2. The exact request URL and method
3. Your `client_id` (from `/v1/keys/current`, **not** the api\_key itself)
4. 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.
# Exorde Intel API
Source: https://docs.exorde.io/index
Structured narrative intelligence from public web and social conversation. Trending, narrative, alerts, clusters, entities, evidence — one API.
## A real signal from yesterday
On 2026-05-18 at 04:00 UTC the term **"dark web"** crossed **6.67 standard deviations** above its 14-day baseline on the `cyber` topic. Spread across 14 domains and 8 languages, validated by our LLM gate, linked to an active cluster of fact-checker-verified breach disclosures.
```json theme={null}
{
"alert_id": "c80fcfed-6818-44ed-a0b9-0eda91d1401c",
"detected_at": "2026-05-18T04:00:30.148Z",
"topic": "cyber",
"signal_type": "volume_spike",
"keyword": "dark web",
"confidence": 0.72,
"severity": { "deviation_sigma": 6.67, "current_value": 24.0, "baseline_value": 3.29 },
"spread": { "domain_count": 14, "language_count": 8 },
"llm_validated": true,
"description": "Multiple credible data breach disclosures (Turkish breach, FoxIT, gaming accounts) surfacing on dark web with fact-checker verification signals genuine cybersecurity incidents being reported and discussed across platforms.",
"matched_cluster": { "cluster_id": 258, "cluster_title": "Dark-web breach disclosures, May 2026" }
}
```
That payload — typed, push-deliverable, IOC-extracted, cluster-linked — is what a SOC analyst, a newsroom desk, or a threat-intel team gets at the moment a story is forming. Not a dashboard screenshot. Not a CSV export. A structured event your code can route, dedupe, and act on.
This is the **Exorde Intel API**.
## What this is
The API turns roughly four billion public posts per month into structured intelligence: which terms spike, which storylines dominate, which platforms carry them, which entities co-occur, which posts are the evidence. The same pipeline powers internal newsroom dashboards, OSINT desks, and brand-risk teams across Europe.
Three things make it production-grade, not a demo:
* **One contract for two scopes.** Curated topics (`global`, `cyber`, `finance`, `disinfo`) and your own custom watchlists expose the **same** analytics surface. Code written for one works on the other.
* **Typed errors with `trace_id`.** Every non-2xx response carries a stable `error` enum and a 16-hex `trace_id` you can quote in support requests. No string-matching, no guessing. We resolve tickets in one round trip.
* **No surprise scope.** `GET /v1/me` and `GET /v1/keys/current` return your tier, topic scope, rate limit, and quotas. Build tier-aware UIs without polling billing.
## Three signals, one pipeline
Top terms ranked by rolling z-score against a 14-day baseline. The state of the conversation in one call.
Editorial summary of the dominant storyline plus weighted sub-narratives. *(alpha — voice and tailoring evolving with design partners.)*
LLM-validated volume-spike events with severity, spread, IOCs, sample posts, and matched cluster context.
Trending is the **state of the words**, narrative is the **state of the story**, alerts are the **events that compose it**. Most production deployments use all three — trending and narrative as dashboard tiles, alerts as push-shaped routing into Slack, Teams, PagerDuty, or custom webhooks.
## Three tiers
Awareness layer. Trending, volume, narrative, alerts. **Trial: 7 days, free, scoped to `global`, no credit card.**
Investigation layer. Adds clusters, entities, search, platforms, posts. Watchlists, webhooks.
Intelligence layer. Adds editorial reports, all topics, larger watchlists, dedicated support, custom voice tailoring.
Full matrix in [Tiers and quotas](/tiers).
## 60-second start
```bash theme={null}
# 1. Mint a free trial key
curl -X POST https://intel-v1.exorde.io/v1/keys/trial \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}'
# 2. Pull what's trending on the global conversation right now
curl https://intel-v1.exorde.io/v1/topics/global/trending \
-H "X-API-Key: exd_trial_..."
# 3. Catch the latest event-shaped signals
curl "https://intel-v1.exorde.io/v1/topics/global/alerts?hours=24" \
-H "X-API-Key: exd_trial_..."
```
Full walkthrough — Python, Node, PowerShell, response samples, error handling — in [Quickstart](/quickstart).
## What the data looks like
A real `trending` response on `global` returns ranked terms with rolling z-scores and 24-hour deltas:
```json theme={null}
{
"topic": "global",
"snapshot_id": "2026-05-19T13:00:00Z",
"terms": [
{ "term": "Iran", "score": 847.2, "rank": 1, "delta_24h": 6.6 },
{ "term": "Eurovision", "score": 412.0, "rank": 2, "delta_24h": 1.8 },
{ "term": "ECB", "score": 289.4, "rank": 3, "delta_24h": 0.9 }
],
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" }
}
```
A real `narrative` response composes those terms into a story:
```json theme={null}
{
"topic": "global",
"summary": "Regional escalation between Iran and Israel dominates global conversation, with overlapping ECB rate signal and Eurovision aftermath as secondary threads.",
"sub_narratives": [
{ "title": "Iran-Israel exchange", "weight": 0.61 },
{ "title": "ECB June pivot rumours", "weight": 0.22 },
{ "title": "Eurovision aftermath", "weight": 0.17 }
]
}
```
Same envelope shape on watchlists. Same typed error path on every endpoint. Same `trace_id` correlation header on every response.
## Where to go next
Mint a trial and run trending, narrative, and alerts in five minutes.
Newsroom, brand, threat-intel, disinfo, executive dashboard — five copy-paste recipes.
Curated topics out of the box, custom watchlists for anything else. Same analytics surface for both.
Watch / See / Know matrix, history caps, result-size caps, webhook quotas.
Mint, rotate, revoke. Test-mode keys. The typed error envelope.
RPM, burst, headers, polling cadence, backoff patterns.
Every error code, what triggers it, what to do.
Every endpoint, live try-it, generated from the OpenAPI spec.
## Status, support, contact
* **Live status** — [intel-v1.exorde.io/v1/status](https://intel-v1.exorde.io/v1/status). Per-stream freshness, never cached more than 30 seconds.
* **OpenAPI spec** — [intel-v1.exorde.io/v1/openapi.json](https://intel-v1.exorde.io/v1/openapi.json). Generate clients in any language.
* **Support** — [intel@exorde.io](mailto:intel@exorde.io). Include the `trace_id` from the error envelope; we resolve in one round trip.
* **Design partner program** — narrative is in alpha. If you want to shape the per-customer voice and brief format, email the same address.
* **Changelog** — [/changelog](/changelog).
Last reviewed: 2026-05-19. API version 1.2.8.
# Narrative
Source: https://docs.exorde.io/narrative
Editorial summary of the dominant storyline on a topic or watchlist, with sub-narrative weights and source diversity. Currently in alpha — actively shaping with design partners.
**Narrative is in alpha.** The endpoint is live and stable enough to ship behind, but the **output format, summarisation depth, and per-customer tailoring** are actively evolving. We are deliberately keeping this surface flexible while we co-design it with the first wave of customers.
If you have an opinion on how a narrative summary should read for **your** use case — newsroom desk-ready prose, SOC-grade incident framing, brand-risk tone, regulator-facing neutrality — **email [intel@exorde.io](mailto:intel@exorde.io)** and we will route your input directly into the roadmap. The customers who reach out now are the ones whose voice shapes the v1 contract.
The `narrative` endpoint returns an **editorial summary** of the dominant storyline on a topic or watchlist over the last rolling window, plus a structured breakdown of sub-narratives and their relative weights. Where [trending](/trending) gives you words and [alerts](/alerts) gives you events, narrative gives you the **story** — written in English by the editorial summarisation pipeline, ready to drop into a tile, a digest, or a brief.
| Surface | Question | Shape |
| --------------------- | -------------------------- | ----------------------------------------- |
| [Trending](/trending) | "Which words are spiking?" | Ranked list with z-scores |
| **Narrative** | "What is the story?" | Editorial summary + sub-narrative weights |
| [Alerts](/alerts) | "What just happened?" | Discrete events with severity |
***
## What "alpha" means here
Concretely, today:
* **The endpoint shape is stable.** `summary`, `sub_narratives[]`, `data_freshness`, `snapshot_id` are the contract — these fields will not be removed without a major version bump. Build against them now.
* **The summary length, voice, and sectioning are evolving.** Today summaries are 1–3 sentences in a neutral editorial register. We are actively prototyping longer-form briefs, audience-tuned voices (newsroom / SOC / brand / regulator), and structured "what / who / where / why" breakdowns.
* **Per-customer tailoring is on the immediate roadmap.** Know-tier customers will be able to specify register, length, audience, and inclusion/exclusion preferences for the narrative on their own watchlists and private topics. **The shape of that configuration is being decided now**, with input from design-partner customers.
* **Historical narratives** (`/narratives/history`) are stable and unaffected by this alpha — that surface is See/Know-tier production.
If your team needs a specific output shape (a daily desk-ready brief, a SOC ticket-ready paragraph, a regulator-facing neutral synopsis), tell us and we will build for it. The design partner program is open and unpaid — you get early access to the tailored output, we get the requirements signal.
***
## The endpoints
| Endpoint | Tier | Returns | Status |
| ------------------------------------------- | ------ | ----------------------------------- | --------- |
| `GET /v1/topics/{topic}/narrative` | Watch+ | Latest narrative on a curated topic | **alpha** |
| `GET /v1/watchlists/{id}/narrative` | See+ | Latest narrative on your watchlist | **alpha** |
| `GET /v1/topics/{topic}/narratives/history` | See+ | Time-series of narrative shifts | stable |
| `GET /v1/topics/{topic}/narratives/posts` | See+ | Source posts behind a narrative | stable |
This page covers the **latest narrative** endpoints. History and evidence are documented in [Topics and watchlists](/topics-and-watchlists#analytics-endpoints-on-curated-topics).
***
## The response envelope
```json theme={null}
{
"topic": "global",
"snapshot_id": "2026-05-19T13:00:00Z",
"summary": "Regional escalation between Iran and Israel dominates global conversation, with overlapping ECB rate signal and Eurovision aftermath as secondary threads. Coverage spans Western news, regional Persian and Hebrew sources, and platform-native commentary.",
"sub_narratives": [
{
"title": "Iran-Israel exchange",
"weight": 0.61,
"lead_terms": ["iran", "israel", "tehran", "idf"],
"domain_count": 142,
"language_count": 11
},
{
"title": "ECB June pivot rumours",
"weight": 0.22,
"lead_terms": ["ecb", "lagarde", "rate cut"],
"domain_count": 67,
"language_count": 8
},
{
"title": "Eurovision aftermath",
"weight": 0.17,
"lead_terms": ["eurovision", "winner", "voting"],
"domain_count": 54,
"language_count": 14
}
],
"source_diversity": { "domain_count": 263, "language_count": 21 },
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" },
"query_window": { "hours": 24, "effective_hours": 24 }
}
```
### Field guide
| Field | Stability | Meaning |
| --------------------------------- | ------------------------------- | ------------------------------------------------------ |
| `topic` / `watchlist_id` | stable | Scope identifier |
| `snapshot_id` | stable | Pipeline run for reproducibility |
| `summary` | **alpha — wording evolving** | Editorial-grade English, currently 1–3 sentences |
| `sub_narratives[]` | stable shape, **alpha titling** | Threads composing the dominant story |
| `sub_narratives[].title` | **alpha — wording evolving** | Short label suitable for a list item |
| `sub_narratives[].weight` | stable | Fraction of the narrative volume (0.0–1.0, sums ≈ 1.0) |
| `sub_narratives[].lead_terms` | stable | Top terms anchoring this sub-narrative |
| `sub_narratives[].domain_count` | stable | Distinct source domains |
| `sub_narratives[].language_count` | stable | Distinct languages |
| `source_diversity` | stable | Aggregate breadth across all sub-narratives |
| `data_freshness` | stable | Snapshot age + traffic-light level |
The **structural fields** (`weight`, `lead_terms`, `domain_count`, `language_count`, `source_diversity`) are the safe ones to build automation against today. The **prose fields** (`summary`, `sub_narratives[].title`) are where we expect the most evolution as we tailor for customers.
***
## Calling it
```bash curl theme={null}
curl https://intel-v1.exorde.io/v1/topics/global/narrative \
-H "X-API-Key: $EXORDE_API_KEY"
```
```python Python theme={null}
import os, httpx
r = httpx.get(
"https://intel-v1.exorde.io/v1/topics/global/narrative",
headers={"X-API-Key": os.environ["EXORDE_API_KEY"]},
timeout=10,
).json()
print(r["summary"])
print()
for sub in r["sub_narratives"]:
print(f" {sub['weight']:>5.1%} {sub['title']}")
print(f" terms: {', '.join(sub['lead_terms'][:4])}")
print(f" spread: {sub['domain_count']}d × {sub['language_count']}l")
```
```javascript Node theme={null}
const r = await fetch(
"https://intel-v1.exorde.io/v1/topics/global/narrative",
{ headers: { "X-API-Key": process.env.EXORDE_API_KEY } }
).then(r => r.json());
console.log(r.summary);
r.sub_narratives.forEach(s =>
console.log(` ${(s.weight * 100).toFixed(1)}% ${s.title}`)
);
```
```powershell PowerShell theme={null}
$r = Invoke-RestMethod `
-Uri "https://intel-v1.exorde.io/v1/topics/global/narrative" `
-Headers @{ "X-API-Key" = $env:EXORDE_API_KEY }
$r.summary
$r.sub_narratives | Select-Object weight, title, domain_count, language_count | Format-Table
```
***
## Reading the response
### `summary`
Plain English, neutral register, **drop straight into a Slack tile or email digest**. Today's summaries are 1–3 sentences. They are designed to be readable by a non-technical reader (an exec, a comms lead, a duty editor) without context.
Do **not** parse the summary in code. Treat it as an opaque string. If your application needs structured fields (entities mentioned, sentiment, geography), use the `sub_narratives[]` breakdown or pivot into [entities](/topics-and-watchlists#analytics-endpoints-on-curated-topics) and [clusters](/topics-and-watchlists#analytics-endpoints-on-curated-topics).
### `sub_narratives[]`
The dominant story is rarely monolithic — it's usually composed of 2–5 threads with different emphases. `sub_narratives[]` exposes those threads with weights summing to roughly 1.0. Use them to:
* power a "what's in the mix" expandable on a dashboard,
* detect a pivot (a thread's weight jumping snapshot-over-snapshot),
* route to the right team (an Iran-Israel sub-narrative on `global` goes to the foreign desk; an ECB sub-narrative goes to the markets desk).
### `source_diversity`
The breadth of the conversation. A narrative with 263 domains and 21 languages is a genuinely global story; one with 12 domains and 2 languages is regional or niche. Useful as a filter for "is this worth surfacing to leadership."
***
## Cadence
Narrative regenerates on the same snapshot cadence as trending — every few minutes. The summarisation step is more expensive than ranking, so individual narrative responses may lag a snapshot or two behind trending under heavy load. `data_freshness.snapshot_age_seconds` always reflects the actual age of the data backing this narrative.
| Use case | Tier | Cadence |
| ------------------- | ---------- | ------------------------------ |
| Dashboard tile | Watch | every 5 min |
| Hourly exec digest | See | every 60 min |
| Daily desk brief | See / Know | every 24h |
| Real-time wallboard | Know | every 5 min (snapshot cadence) |
Polling faster than the snapshot cadence is wasted RPM. See [Rate limits](/rate-limits).
***
## Narrative on a watchlist
Same shape, scoped to your watchlist's terms. Particularly useful for **brand and risk monitoring** — the narrative around your scope often diverges from the topic baseline.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../narrative \
-H "X-API-Key: $EXORDE_API_KEY"
```
A real example on an `acme-monitoring` watchlist might surface a sub-narrative around an executive announcement that is invisible at the `global` level — exactly the kind of "what is *my* story" view that benefits most from per-customer voice tailoring.
***
## Historical narratives (stable surface)
`/v1/topics/{topic}/narratives/history` returns the time-series of narrative shifts — when sub-narratives entered, peaked, and faded. Same envelope shape as the latest endpoint, plus a `narratives[]` array indexed by snapshot. Available See+ and **not in alpha** — the contract is stable. See [Topics and watchlists](/topics-and-watchlists#analytics-endpoints-on-curated-topics).
***
## Roadmap and how to influence it
The shortlist of items actively under design, in priority order:
1. **Audience-tuned voice.** Per-key configuration of register: `editorial`, `analyst`, `executive`, `regulator`. Same data, different tone.
2. **Per-customer brief format.** Optional structured sections (`what`, `who`, `where`, `why_it_matters`) for customers building automated briefs.
3. **Length controls.** `concise` (1 sentence), `default` (1–3), `brief` (paragraph), `report` (multi-paragraph with sub-headers).
4. **Inclusion/exclusion lexicons.** Customer-specific stopwords and "always mention if present" terms baked into summarisation.
5. **Narrative deltas.** A `vs_previous_snapshot` field describing what changed — "ECB pivot rumours overtook Eurovision as the second-largest thread."
6. **Multi-language summaries.** French, German, Spanish, Arabic native summaries — not translations.
**This list is not fixed.** It will move based on what design-partner customers ask for. If your priority isn't here, [tell us](mailto:intel@exorde.io) — we are explicitly looking for the use cases we haven't seen yet.
### How to engage
* **Email [intel@exorde.io](mailto:intel@exorde.io)** with one paragraph: who you are, what scope you'd use narrative on, what shape of output would make it indispensable for your workflow.
* **Or book a 30-minute design call** via the same address — we run these weekly with prospective design partners.
* **Or just send the JSON shape you wish you got back** and we'll tell you whether and when we can ship it.
The earliest customers shape the contract. Once we lock v1, the surface stabilises and tailoring moves behind a "preference profile" rather than a co-design conversation.
***
## Operational guidance
* **Don't parse `summary`.** It's prose, not structured data. Use `sub_narratives[].title` and `lead_terms` for structured logic.
* **Watch `weight` shifts** snapshot-over-snapshot to detect narrative pivots — often the most valuable signal narrative gives you.
* **Pair with [trending](/trending)** for full state coverage: trending shows the spike, narrative shows the story it composes.
* **Pair with [alerts](/alerts)** for full event coverage: an alert is the moment a sub-narrative is born; narrative is what it grew into 30 minutes later.
* **Cite `snapshot_id`** in any downstream artefact (brief, ticket, dashboard) so the analysis is reproducible if the narrative shifts.
* **Build against structural fields first** — given the alpha status of the prose, automation that depends on `weight`, `lead_terms`, `domain_count` is more durable today than automation that depends on `summary` wording.
***
## Errors specific to narrative
| Status | `error` | When |
| ------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------ |
| 404 | `narrative_unavailable` | The pipeline hasn't yet produced a narrative for this scope (very low-volume watchlists or freshly-created scopes) |
| 410 | `snapshot_expired` | Pinned `snapshot_id` is past retention — refetch latest |
| 503 | `service_unavailable` | Summarisation pipeline degraded — retry with backoff |
Full error envelope: [Errors](/errors).
***
## What's not narrative
| You want | Use this |
| ------------------------------------- | ------------------------------------------ |
| "What words are spiking" | [Trending](/trending) |
| "Posts behind this narrative" | `/v1/topics/{t}/narratives/posts` (See+) |
| "How the narrative shifted over time" | `/v1/topics/{t}/narratives/history` (See+) |
| "Editorial weekly intelligence brief" | `/v1/topics/{t}/reports/latest` (Know) |
| "Push me when something just broke" | [Alerts](/alerts) |
Narrative is the **state-of-the-story** view at a snapshot. Reports are deeper, scheduled, editorial. Alerts are the events that compose it.
Last reviewed: 2026-05-19. API version 1.2.8. **`summary` and `sub_narratives[].title` wording in alpha — actively shaped by design-partner feedback.**
# Quickstart
Source: https://docs.exorde.io/quickstart
From zero to a live signal in five minutes. Mint a trial, pull what's trending, read the narrative, and catch the next alert.
In the next five minutes you'll go from nothing to a live structured view of the global conversation: the top terms driving it, the editorial summary of the dominant storyline, and the most recent volume-spike alert. Same key, three calls, no setup.
By the end of this page you will have:
* a working trial API key (free, 7 days, no credit card),
* a Python script that prints what's trending on `global` right now,
* a second script that pulls the latest narrative and the loudest recent alert,
* enough understanding of the response shapes to build something real.
If you'd rather skim before you type, the [home page](/) shows live response samples, and [Use cases](/use-cases) has five ready-made recipes.
***
## What you'll be calling
Three endpoints, all Watch-tier, all available on a free trial key, all keyed on the `global` curated topic:
| Endpoint | Returns | Why you care |
| --------------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- |
| `GET /v1/topics/global/trending` | Top terms by rolling z-score | The signal: which terms are spiking right now |
| `GET /v1/topics/global/narrative` | Editorial summary + sub-narrative weights | The story: what is actually being said |
| `GET /v1/topics/global/alerts` | LLM-validated volume spikes with severity, spread, IOCs | The push-shaped event signal — see [Alerts](/alerts) for the full envelope |
Curated topics are the named, stable slices Exorde maintains: `global`, `cyber`, `finance`, `disinfo`. Trial keys are scoped to `global` only — paid tiers unlock the other three. See [Topics and watchlists](/topics-and-watchlists) and [Tiers](/tiers).
***
## Step 1 — Mint a trial key
One unauthenticated POST. Idempotent per email: call it twice with the same address inside the 7-day window and you get the same key back with `reused: true`. No duplicate keys.
```bash curl theme={null}
curl -X POST https://intel-v1.exorde.io/v1/keys/trial \
-H "Content-Type: application/json" \
-d '{"email": "you@example.com"}'
```
```python Python theme={null}
import httpx
r = httpx.post(
"https://intel-v1.exorde.io/v1/keys/trial",
json={"email": "you@example.com"},
timeout=10,
)
r.raise_for_status()
api_key = r.json()["api_key"]
print(api_key)
```
```javascript Node theme={null}
const r = await fetch("https://intel-v1.exorde.io/v1/keys/trial", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: "you@example.com" }),
});
const data = await r.json();
console.log(data.api_key);
```
```powershell PowerShell theme={null}
$body = @{ email = "you@example.com" } | ConvertTo-Json
$r = Invoke-RestMethod `
-Method Post `
-Uri "https://intel-v1.exorde.io/v1/keys/trial" `
-ContentType "application/json" `
-Body $body
$r.api_key
```
The response includes everything your code needs to know about the key it just got:
```json theme={null}
{
"api_key": "exd_trial_QLocUNNcjQ7TXxTgJZ2DWww4QxjlLBgc",
"client_id": "trial_3c91be7f",
"tier": "watch",
"topics": ["global"],
"rate_limit_rpm": 30,
"monthly_call_quota": 5000,
"expires_at": "2026-05-26T13:00:00Z",
"reused": false
}
```
**Watch-tier**, **30 RPM**, **`global` only**, **expires in 7 days**. Full lifecycle (rotate, revoke, re-mint) is in [Authentication](/authentication).
***
## Step 2 — Save it once, use it everywhere
Every authenticated request carries `X-API-Key`. Export the key as an environment variable so the snippets below work as-is.
```bash bash / zsh theme={null}
export EXORDE_API_KEY="exd_trial_..."
```
```powershell PowerShell (session) theme={null}
$env:EXORDE_API_KEY = "exd_trial_..."
```
```powershell PowerShell (persistent) theme={null}
[Environment]::SetEnvironmentVariable("EXORDE_API_KEY", "exd_trial_...", "User")
```
Treat keys like passwords. Never commit them, never embed them in a public SPA bundle, never email in plaintext. If a key leaks, [rotate it](/authentication#rotating-a-key) — atomic, free, instantaneous.
***
## Step 3 — What's trending on `global` right now
The `trending` endpoint returns the top terms ranked by rolling z-score over the latest snapshot, deduped, with 24-hour delta.
```bash curl theme={null}
curl https://intel-v1.exorde.io/v1/topics/global/trending \
-H "X-API-Key: $EXORDE_API_KEY"
```
```python Python theme={null}
import os, httpx
r = httpx.get(
"https://intel-v1.exorde.io/v1/topics/global/trending",
headers={"X-API-Key": os.environ["EXORDE_API_KEY"]},
timeout=10,
)
r.raise_for_status()
for t in r.json()["terms"][:10]:
print(f"{t['rank']:>2}. {t['term']:<25} z={t['score']:>7.1f} Δ24h={t['delta_24h']:+.1f}")
```
```javascript Node theme={null}
const r = await fetch(
"https://intel-v1.exorde.io/v1/topics/global/trending",
{ headers: { "X-API-Key": process.env.EXORDE_API_KEY } }
);
const data = await r.json();
data.terms.slice(0, 10).forEach(t =>
console.log(`${t.rank}. ${t.term} z=${t.score.toFixed(1)} Δ24h=${t.delta_24h}`)
);
```
```powershell PowerShell theme={null}
$r = Invoke-RestMethod `
-Uri "https://intel-v1.exorde.io/v1/topics/global/trending" `
-Headers @{ "X-API-Key" = $env:EXORDE_API_KEY }
$r.terms | Select-Object -First 10 rank, term, score, delta_24h | Format-Table
```
A real response looks like this:
```json theme={null}
{
"topic": "global",
"snapshot_id": "2026-05-19T13:00:00Z",
"terms": [
{ "term": "Iran", "score": 847.2, "rank": 1, "delta_24h": 6.6 },
{ "term": "Eurovision", "score": 412.0, "rank": 2, "delta_24h": 1.8 },
{ "term": "ECB", "score": 289.4, "rank": 3, "delta_24h": 0.9 }
],
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" }
}
```
`snapshot_id` pins the response to a specific pipeline run — useful for reproducibility. `data_freshness` tells you how old the underlying data is and whether the pipeline is healthy. Snapshots refresh every few minutes.
***
## Step 4 — What's the dominant storyline
`trending` gives you words. `narrative` gives you the story they form, written by the editorial-grade summarisation pipeline.
```bash curl theme={null}
curl https://intel-v1.exorde.io/v1/topics/global/narrative \
-H "X-API-Key: $EXORDE_API_KEY"
```
```python Python theme={null}
import os, httpx
r = httpx.get(
"https://intel-v1.exorde.io/v1/topics/global/narrative",
headers={"X-API-Key": os.environ["EXORDE_API_KEY"]},
timeout=10,
).json()
print(r["summary"])
print()
for sub in r.get("sub_narratives", [])[:3]:
print(f" - {sub['weight']:>5.1%} {sub['title']}")
```
The response is structured for both a one-line tile and a deeper drilldown:
```json theme={null}
{
"topic": "global",
"snapshot_id": "2026-05-19T13:00:00Z",
"summary": "Regional escalation between Iran and Israel dominates global conversation, with overlapping ECB rate signal and Eurovision aftermath as secondary threads.",
"sub_narratives": [
{ "title": "Iran-Israel exchange", "weight": 0.61 },
{ "title": "ECB June pivot rumours", "weight": 0.22 },
{ "title": "Eurovision aftermath", "weight": 0.17 }
],
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" }
}
```
Drop `summary` straight into a Slack tile, a dashboard header, or an email digest — it's editorial-grade English, not a keyword bag.
***
## Step 5 — Catch the next event with `alerts`
Trending and narrative are the **state** of the conversation. Alerts are **events**: keywords whose volume just crossed five sigma above their 14-day baseline, validated by an LLM gate, with severity and spread metadata you can route on.
```bash curl theme={null}
curl "https://intel-v1.exorde.io/v1/topics/global/alerts?hours=24&limit=10" \
-H "X-API-Key: $EXORDE_API_KEY"
```
```python Python theme={null}
import os, httpx
r = httpx.get(
"https://intel-v1.exorde.io/v1/topics/global/alerts",
params={"hours": 24, "limit": 10, "llm_validated": True},
headers={"X-API-Key": os.environ["EXORDE_API_KEY"]},
timeout=10,
).json()
if not r["alerts"]:
print("Quiet last 24h on global — try hours=168.")
for a in r["alerts"]:
sev = a["severity"]
print(f"σ={sev['deviation_sigma']:.2f} {a['keyword']:<25} "
f"{a['spread']['domain_count']}d × {a['spread']['language_count']}l")
print(f" {a['description']}\n")
```
A real alert (cyber topic, paid tier — same envelope on `global`):
```json theme={null}
{
"alert_id": "c80fcfed-6818-44ed-a0b9-0eda91d1401c",
"detected_at": "2026-05-18T04:00:30.148Z",
"topic": "cyber",
"signal_type": "volume_spike",
"keyword": "dark web",
"severity": { "deviation_sigma": 6.67, "current_value": 24.0, "baseline_value": 3.29 },
"spread": { "domain_count": 14, "language_count": 8 },
"llm_validated": true,
"description": "Multiple credible data breach disclosures (Turkish breach, FoxIT software, gaming accounts) surfacing on dark web with fact-checker verification..."
}
```
The default `hours=168` returns the last week — useful because low-volume topics may emit zero alerts in a 24-hour window. Full envelope, signal types, IOC schema, webhook delivery: [Alerts](/alerts).
***
## What you've actually built
Three calls, three different shapes of intelligence, one API key:
| Call | Question it answers | Shape |
| ------------ | ------------------------- | ----------------------------------------- |
| `/trending` | "What words are spiking?" | Ranked list with z-scores |
| `/narrative` | "What's the story?" | Editorial summary + sub-narrative weights |
| `/alerts` | "What just happened?" | Discrete events with severity + spread |
That's the full **awareness layer** — Watch-tier, available on any trial. The next layer adds investigation (clusters, entities, search, watchlists) and intelligence (editorial reports, custom topics). See [Tiers](/tiers).
***
## Handling the two errors you'll actually hit
Every non-2xx response is a typed envelope. **Match on the `error` code, never on the prose `message`.** A 16-hex `trace_id` rides on every response (header `X-Exorde-Trace-Id`); quote it in support tickets and we resolve in one round trip.
```json theme={null}
{
"error": "upgrade_required",
"message": "This endpoint requires the 'see' tier",
"feature": "clusters",
"current_tier": "watch",
"required_tier": "see",
"upgrade": true,
"trace_id": "8b3a47ce91d04f17"
}
```
The two you'll hit on a trial:
| `error` | When | Fix |
| ------------------ | --------------------------------------------------------------------- | --------------------------------------- |
| `upgrade_required` | You called a See/Know endpoint (clusters, entities, reports, search…) | Stay on Watch endpoints or upgrade |
| `rate_limited` | More than 30 requests in 60 seconds | Sleep `Retry-After` seconds, then retry |
Backoff pattern with header preference:
```python theme={null}
import time, httpx
def call(url, headers, attempts=5):
delay = 1
for _ in range(attempts):
r = httpx.get(url, headers=headers, timeout=10)
if r.status_code != 429:
return r
wait = int(r.headers.get("Retry-After", delay))
time.sleep(wait)
delay = min(delay * 2, 30)
return r
```
Full code list and patterns: [Errors](/errors) and [Rate limits](/rate-limits).
***
## Rotate or revoke if anything leaks
If a key escapes — committed to git, pasted in Slack, screenshotted — kill it immediately. Rotation preserves your tier, topics, expiry; revocation is permanent.
```bash Rotate theme={null}
curl -X POST https://intel-v1.exorde.io/v1/keys/rotate \
-H "X-API-Key: $EXORDE_API_KEY"
```
```bash Revoke theme={null}
curl -X DELETE https://intel-v1.exorde.io/v1/keys/current \
-H "X-API-Key: $EXORDE_API_KEY"
```
Full lifecycle, replay protection, expiry semantics: [Authentication](/authentication).
***
## Where to go next
The full alert envelope — signal types, severity math, IOCs, matched clusters, webhooks, dedup.
Five copy-paste recipes: newsroom, brand, threat-intel, disinfo, executive dashboard.
Curated topics out of the box; custom watchlists for anything else. Same analytics surface.
Watch / See / Know — what each unlocks, history depth caps, result-size caps.
Mint, rotate, revoke, identity, the typed error envelope.
Every endpoint, live try-it, generated from the OpenAPI spec.
***
## If you get stuck
* **Status** — live per-stream freshness at [intel-v1.exorde.io/v1/status](https://intel-v1.exorde.io/v1/status). Check this first when something looks slow or empty.
* **Support** — [intel@exorde.io](mailto:intel@exorde.io). Include the `trace_id` from the error envelope; we triage faster with it than without.
* **Changelog** — [/changelog](/changelog). What changed, what broke, what's stable.
Last reviewed: 2026-05-19. API version 1.2.8.
# Rate limits
Source: https://docs.exorde.io/rate-limits
How rate limiting works, headers returned on every response, and the correct backoff pattern.
## The two layers
Every request is evaluated against two independent limits on the same key.
1. **Sliding 60-second window** — your tier's requests-per-minute ceiling.
2. **1-second burst cap** — your tier's requests-per-second ceiling.
Whichever limit you hit first triggers a `429 rate_limited` response.
## Per-tier ceilings
| Tier | Requests / minute | Burst (req / sec) |
| ----- | ----------------- | ----------------- |
| Watch | 30 | 5 |
| See | 120 | 20 |
| Know | 600 | 60 |
Exact values are the source of truth in [Tiers and Quotas](/tiers) and are derived directly from `api/config.py`.
## Headers on every response
Every response — success or failure — includes your current window state.
| Header | Meaning |
| ----------------------- | --------------------------------------------------- |
| `X-RateLimit-Limit` | Your tier's per-minute ceiling. |
| `X-RateLimit-Remaining` | Requests remaining in the current 60-second window. |
| `X-RateLimit-Reset` | UNIX seconds until the window resets. |
A `429` additionally carries:
| Header | Meaning |
| ------------- | ------------------------------------------------------- |
| `Retry-After` | Seconds to wait before retrying. Always present on 429. |
## The 429 response body
```json theme={null}
{
"error": "rate_limited",
"message": "Rate limit exceeded. Retry after 1 second.",
"retry_after": 1,
"limit": 30,
"window_seconds": 60
}
```
## Correct backoff pattern
Respect `Retry-After`. Do not retry faster. Do not retry synchronously in a tight loop. Use jitter.
```python Python theme={null}
import os, time, random, httpx
def get_with_backoff(url: str, max_retries: int = 5) -> httpx.Response:
headers = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
for attempt in range(max_retries):
r = httpx.get(url, headers=headers, timeout=10)
if r.status_code != 429:
return r
retry_after = int(r.headers.get("Retry-After", "1"))
sleep_s = retry_after + random.uniform(0, 0.5 * (2 ** attempt))
time.sleep(sleep_s)
r.raise_for_status()
return r
```
```javascript Node theme={null}
async function getWithBackoff(url, maxRetries = 5) {
for (let attempt = 0; attempt < maxRetries; attempt++) {
const r = await fetch(url, {
headers: { "X-API-Key": process.env.EXORDE_API_KEY },
});
if (r.status !== 429) return r;
const retryAfter = Number(r.headers.get("Retry-After") ?? 1);
const jitter = Math.random() * 0.5 * Math.pow(2, attempt);
await new Promise(res => setTimeout(res, (retryAfter + jitter) * 1000));
}
throw new Error("Rate-limit retries exhausted");
}
```
```powershell PowerShell theme={null}
function Invoke-WithBackoff($Uri, $MaxRetries = 5) {
for ($i = 0; $i -lt $MaxRetries; $i++) {
try {
return Invoke-RestMethod -Uri $Uri -Headers @{ "X-API-Key" = $env:EXORDE_API_KEY }
} catch {
if ($_.Exception.Response.StatusCode.value__ -ne 429) { throw }
$retry = [int]$_.Exception.Response.Headers["Retry-After"]
Start-Sleep -Seconds ($retry + (Get-Random -Maximum 1))
}
}
throw "Rate-limit retries exhausted"
}
```
## Planning your request budget
The following heuristics work well in practice.
* **Polling dashboards**: for Watch-tier (30 rpm) poll each topic no faster than every 10 seconds. For See-tier (120 rpm) every 2 seconds is fine.
* **Batch pipelines**: serialise requests, not parallelise, at Watch-tier. At See and Know you can safely run two to six parallel workers.
* **Evidence drill-downs**: post fetches are the slowest endpoints (500–1000 ms). Stagger them.
## Related quotas
Rate limits are distinct from the quotas below, each enforced separately.
* **Alert subscriptions** — cap on the number of active webhooks per key.
* **Custom watchlists** — cap on the number of custom scopes per client.
* **Evidence lookback** — maximum age of posts returned by evidence endpoints.
See [Tiers and Quotas](/tiers) for exact values per tier.
# API Reference
Source: https://docs.exorde.io/reference
Full Exorde Intel API reference with live try-it. Authenticate once, test every endpoint.
The reference below is generated from the live OpenAPI spec at [intel-v1.exorde.io/v1/openapi.json](https://intel-v1.exorde.io/v1/openapi.json). Every endpoint is browsable and testable — click any endpoint, paste your key, hit Send.
To authenticate the try-it panels, enter your `X-API-Key` in the auth field at the top of each endpoint page. Your key is stored in your browser only.
# Tiers and quotas
Source: https://docs.exorde.io/tiers
What you get on Watch, See, and Know — capabilities, limits, rate limits, and history depth — pulled directly from the live config.
The Exorde Intel API has three customer tiers. Two non-customer tiers (`test`, `demo`) exist for QA and the public demo at `intel.exorde.io`; they are not sold and never appear in upgrade flows.
## At a glance
| Capability | Watch | See | Know |
| -------------------------------- | --------------------------------- | ------------------------------- | -------------------------- |
| **Intent** | Awareness — "what's happening" | Investigation — "why and where" | Intelligence — "act on it" |
| **Topic scope per key** | 1 (`global` on trial) | up to 4 | all 4 |
| **Rate limit (rpm)** | 30 | 120 | 600 |
| **Burst** | 5 | 20 | 60 |
| **Active webhook subscriptions** | - | 20 | 50 |
| **Custom watchlists** | — | 4 | 20 |
| **Monthly call quota** | 5,000 | 250,000 | 2,000,000 |
| **Terms per watchlist** | — | 10 | 50 |
| **Trial available** | ✅ 7-day, free, scoped to `global` | on request | on request |
Trial keys are minted via [`POST /v1/keys/trial`](/authentication#minting-a-trial-key). Paid tiers are issued after Stripe checkout.
## Endpoint access by tier
| Endpoint family | Watch | See | Know |
| ------------------------------------------------------------------------------------------------ | -------- | --- | ---- |
| `/v1/health`, `/v1/version`, `/v1/status`, `/v1/topics`, `/v1/docs` | ✅ public | ✅ | ✅ |
| `/v1/keys/trial`, `/v1/keys/current`, `/v1/keys/rotate`, `/v1/me`, `/v1/usage` | ✅ | ✅ | ✅ |
| `/v1/topics/{topic}/trending` | ✅ | ✅ | ✅ |
| `/v1/topics/{topic}/volume`, `/volume/keywords` | ✅ | ✅ | ✅ |
| `/v1/topics/{topic}/narrative` (latest summary) | ✅ | ✅ | ✅ |
| `/v1/topics/{topic}/alerts` | ✅ | ✅ | ✅ |
| `/v1/subscriptions` (webhook delivery) | ✅ | ✅ | ✅ |
| `/v1/topics/{topic}/clusters`, `/clusters/lifecycle`, `/clusters/posts` | ❌ | ✅ | ✅ |
| `/v1/topics/{topic}/entities`, `/entities/cooccurrence`, `/entities/timeline`, `/entities/posts` | ❌ | ✅ | ✅ |
| `/v1/topics/{topic}/narratives/history`, `/narratives/posts` | ❌ | ✅ | ✅ |
| `/v1/topics/{topic}/platforms` | ❌ | ✅ | ✅ |
| `/v1/topics/{topic}/search` | ❌ | ✅ | ✅ |
| `/v1/watchlists` (CRUD + analytics surface) | ❌ | ✅ | ✅ |
| `/v1/watchlists/{id}/clusters` | ❌ | ❌ | ✅ |
| `/v1/topics/{topic}/reports/latest`, `/reports/archive` | ❌ | ❌ | ✅ |
A 403 `upgrade_required` response always carries `current_tier`, `required_tier`, `feature`, and `upgrade: true` so client UIs can render an upgrade CTA without parsing strings.
## History depth caps
The API enforces per-tier caps on how far back you can query each surface. Requests above the cap are silently clamped (no error) — the response will indicate the effective window.
| Surface | Watch | See | Know |
| ----------------------------- | ----- | --- | ---- |
| `alerts` (`hours`) | 24 | 72 | 168 |
| `trending` (`hours`) | 24 | 72 | 168 |
| `volume` (`hours`) | 24 | 168 | 720 |
| `narratives/history` (`days`) | — | 30 | 90 |
| `entities/timeline` (`days`) | — | 30 | 365 |
| `reports` (`days`) | — | 90 | 365 |
| Evidence `posts` (`days`) | 1 | 3 | 7 |
## Result-size caps
Hard ceilings on `limit` parameters across endpoints.
| Endpoint | Watch | See | Know |
| ------------------ | ----- | --- | ---- |
| `clusters` | 20 | 100 | 500 |
| `entities` | 20 | 100 | 500 |
| `search` | 20 | 50 | 200 |
| `platforms` | 20 | 100 | 500 |
| `watchlists/posts` | — | 100 | 500 |
Requesting `limit=10000` on a Watch key returns at most 20 items. Use pagination cursors where supported.
## What happens at quota limits
| Limit | Response | Recovery |
| ------------------------ | ----------------------------------------------------------------- | ------------------------------- |
| Per-minute RPM exceeded | `429 rate_limited` with `Retry-After` and `X-RateLimit-*` headers | See [Rate limits](/rate-limits) |
| Webhook subscription cap | `429 subscription_limit_reached` with `current`, `limit`, `tier` | Delete one or upgrade |
| Watchlist cap | `429 watchlist_limit_reached` with `current`, `limit`, `tier` | Delete one or upgrade |
| Watchlist terms cap | `422 watchlist_term_limit_reached` | Reduce terms or upgrade |
## Topic catalog
The four customer-facing curated topics:
| Topic | Tracks |
| --------- | -------------------------------------------------- |
| `global` | Top storylines across the global conversation |
| `cyber` | Cybersecurity incidents, threat actors, breaches |
| `finance` | Markets, central banks, crypto, earnings |
| `disinfo` | Coordinated narrative ops, bot activity, deepfakes |
Additional curated slices (defense, energy, geopolitics, MENA, etc.) are available to enterprise Know customers on request — contact [intel@exorde.io](mailto:intel@exorde.io).
## Test and demo tiers
`exd_test_*` keys serve fixture data for deterministic plumbing tests. They do not cost quota and are not issued to customers. Production data requires a paid key. The public demo at `intel.exorde.io` runs on a shared `demo` tier key with elevated rate limits.
# Topics and watchlists
Source: https://docs.exorde.io/topics-and-watchlists
Curated topics out of the box. Custom watchlists for anything else. Same analytics surface for both.
## Two scopes, one analytics surface
Every analytical endpoint in the Intel API runs against a **scope**. There are two kinds, and both expose the same surface — the only thing that changes is the path prefix.
| Scope | What | Maintained by | Available on |
| -------------------- | ------------------------------------------------------------- | ------------- | ------------------------------- |
| **Curated topic** | Named, stable slice (`global`, `cyber`, `finance`, `disinfo`) | Exorde | Every tier; topic scope per key |
| **Custom watchlist** | Your own keyword, phrase, entity, or domain scope | You | See and Know |
Both expose [trending](/trending), volume, volume-by-keyword, [narrative](/narrative), entities, platforms, posts, and [alerts](/alerts). Cluster analytics on watchlists require Know.
A useful mental model: a **curated topic** is a slice Exorde already runs at scale with a learned 14-day baseline; a **watchlist** is the same machinery pointed at terms you define. Same code path, same envelope shape, same ranking math.
***
## Curated topics
The four public curated topics:
| Topic | What it tracks | Typical signal |
| --------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| `global` | Top storylines across the global conversation — geopolitics, macro, mainstream news | Iran-Israel exchange, ECB rate decision, Eurovision aftermath |
| `cyber` | Cybersecurity incidents, threat actors, vulnerabilities, ransomware, breaches | "dark web" 6.67σ spike on breach disclosures |
| `finance` | Markets, central banks, crypto, earnings, macro indicators | Fed pivot rumours, Bitcoin ETF flows, earnings beats |
| `disinfo` | Coordinated narrative ops, bot activity, deepfake claims, info-warfare signals | Multi-platform synchronised posting around an election |
Additional curated slices (defense, energy, geopolitics, MENA, ai\_tech, named-entity slices) are available to enterprise Know customers on request — email [intel@exorde.io](mailto:intel@exorde.io) with the desired scope.
Topic access is **scoped per key**. `GET /v1/me` returns your `topics` array. A Watch-tier trial is scoped to `global` only.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/topics/cyber/trending \
-H "X-API-Key: $EXORDE_API_KEY"
```
If you call a topic your key isn't scoped for, you get `403 topic_denied`. The error envelope tells you which topics you do have:
```json theme={null}
{
"error": "topic_denied",
"message": "Key not authorised for topic 'cyber'",
"requested_topic": "cyber",
"allowed_topics": ["global"],
"trace_id": "8b3a47ce91d04f17"
}
```
### Listing topics
```bash theme={null}
curl https://intel-v1.exorde.io/v1/topics \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"topics": [
{ "topic": "global", "is_default": true, "description": "Global conversation" },
{ "topic": "cyber", "is_default": false, "description": "Cybersecurity" },
{ "topic": "finance", "is_default": false, "description": "Markets & macro" },
{ "topic": "disinfo", "is_default": false, "description": "Information operations" }
],
"count": 4,
"default_topic": "global"
}
```
***
## Analytics endpoints on curated topics
The full surface, by tier. Concept pages cover the response envelopes in depth.
| Endpoint | Purpose | Watch | See | Know |
| -------------------------------------------------------------------------------------- | --------------------------------------------------- | ----- | --- | ---- |
| [`/v1/topics/{t}/trending`](/trending) | Top terms by rolling z-score | ✅ | ✅ | ✅ |
| `/v1/topics/{t}/volume` | Time-series of conversation volume | ✅ | ✅ | ✅ |
| `/v1/topics/{t}/volume/keywords` | Per-keyword volume breakdown | ✅ | ✅ | ✅ |
| [`/v1/topics/{t}/narrative`](/narrative) | Editorial summary + sub-narrative weights *(alpha)* | ✅ | ✅ | ✅ |
| [`/v1/topics/{t}/alerts`](/alerts) | LLM-validated volume-spike alerts | ✅ | ✅ | ✅ |
| `/v1/topics/{t}/clusters` | Conversation clusters with titles + entities | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/entities` | Named-entity leaderboard | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/entities/cooccurrence` | Entity co-occurrence graph | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/entities/timeline` | Entity mention time-series | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/platforms` | Source-platform breakdown | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/search` | Full-text search across topic | ❌ | ✅ | ✅ |
| [`/v1/topics/{t}/narratives/history`](/narrative#historical-narratives-stable-surface) | Time-series of narrative shifts | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/posts` (cluster/entity/narrative evidence) | Source posts | ❌ | ✅ | ✅ |
| `/v1/topics/{t}/reports/latest` | Editorial intelligence reports | ❌ | ❌ | ✅ |
| `/v1/topics/{t}/reports/archive` | Historical reports | ❌ | ❌ | ✅ |
Hitting an endpoint above your tier returns `403 upgrade_required` with `current_tier`, `required_tier`, and `feature` in the envelope. See [Errors](/errors).
***
## Custom watchlists
Watchlists are **your private scopes**. You define the terms; the API runs the same analytics against the matching post stream.
### Term types
| Type | Matches | Example |
| --------- | ------------------------------------------------------- | ---------------------- |
| `keyword` | Substring match against entity names and cluster themes | `ransomware` |
| `phrase` | Same mechanism as keyword; may contain spaces | `zero day exploit` |
| `entity` | Exact lowercase match on cluster entities | `lockbit` |
| `domain` | Exact match on cluster top domains | `bleepingcomputer.com` |
Mixed-type watchlists work and are common. A brand watchlist typically combines `keyword` (brand name + variants), `domain` (corporate sites), and `entity` (key executives). A threat-intel watchlist combines `entity` (named threat actors) and `keyword` (CVE families, malware names).
### Create a watchlist
```bash theme={null}
curl -X POST https://intel-v1.exorde.io/v1/watchlists \
-H "X-API-Key: $EXORDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "acme-monitoring",
"base_topic": "global",
"terms": [
{ "type": "keyword", "value": "acme" },
{ "type": "keyword", "value": "acme corp" },
{ "type": "domain", "value": "acme.com" },
{ "type": "entity", "value": "jane doe" }
]
}'
```
```json theme={null}
{
"id": "wl_01HXYZ7DKMSV2X9WJEQ5RQ4N3F",
"client_id": "see_a8c41d92",
"name": "acme-monitoring",
"base_topic": "global",
"terms": [
{ "type": "keyword", "value": "acme" },
{ "type": "keyword", "value": "acme corp" },
{ "type": "domain", "value": "acme.com" },
{ "type": "entity", "value": "jane doe" }
],
"term_count": 4,
"created_at": "2026-05-19T13:00:00Z",
"updated_at": "2026-05-19T13:00:00Z"
}
```
`base_topic` defines the universe the watchlist filters from. `global` matches the broadest stream; `cyber` filters to cybersecurity-tagged posts only. Most brand watchlists base on `global`; most threat-intel watchlists base on `cyber`.
### Query a watchlist
The full analytics surface mirrors topic endpoints, with `/watchlists/{id}/...` instead of `/topics/{topic}/...`. Same JSON shapes, same tier gates.
```bash theme={null}
# All See-tier:
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../trending -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../narrative -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../alerts -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../entities -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../platforms -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../volume -H "X-API-Key: $K"
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../posts -H "X-API-Key: $K"
# Know-tier only:
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../clusters -H "X-API-Key: $K"
```
A trending response on a watchlist:
```json theme={null}
{
"watchlist_id": "wl_01HXYZ7DKMSV2X9WJEQ5RQ4N3F",
"name": "acme-monitoring",
"snapshot_id": "2026-05-19T13:00:00Z",
"terms": [
{ "term": "acme", "score": 142.3, "rank": 1, "delta_24h": 0.4 },
{ "term": "ceo", "score": 87.1, "rank": 2, "delta_24h": 1.2 },
{ "term": "earnings", "score": 61.8, "rank": 3, "delta_24h": 3.7 }
],
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" }
}
```
Identical envelope to [`/topics/{t}/trending`](/trending), with `watchlist_id` + `name` swapping in for `topic`. **Code written for one works on the other.**
### Update or delete
```bash theme={null}
# Rename
curl -X PATCH https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ... \
-H "X-API-Key: $EXORDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "acme-brand-q3"}'
# Replace terms (whole-array replacement)
curl -X PATCH https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ... \
-H "X-API-Key: $EXORDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"terms": [{"type":"keyword","value":"acme"},{"type":"domain","value":"acme.com"}]}'
# Delete
curl -X DELETE https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ... \
-H "X-API-Key: $EXORDE_API_KEY"
```
PATCH on `terms` is **whole-array replacement**, not a delta. Send the full intended term list; anything missing from the new array is removed.
### Limits and validation
| Quota | Watch | See | Know |
| ------------------- | ----- | --- | ---- |
| Custom watchlists | — | 4 | 20 |
| Terms per watchlist | — | 10 | 50 |
Hitting a cap returns a typed envelope:
```json theme={null}
{
"error": "watchlist_limit_reached",
"message": "Your tier allows up to 4 watchlists",
"current_tier": "see",
"limit": 4,
"current": 4,
"trace_id": "8b3a47ce91d04f17"
}
```
Validation errors at create/patch time:
| `error` | When |
| ------------------------------ | -------------------------------------------------- |
| `watchlist_term_limit_reached` | More than allowed terms in payload |
| `validation_error` | Empty `terms`, missing `name`, invalid `term_type` |
| `unknown_topic` | `base_topic` not in curated set |
| `duplicate_watchlist_name` | Name already used by this client |
Full list: [Errors](/errors).
***
## Webhooks (push delivery)
See and Know tiers can register webhook URLs to receive alerts and watchlist signals as they fire, instead of polling.
```bash theme={null}
curl -X POST https://intel-v1.exorde.io/v1/subscriptions \
-H "X-API-Key: $EXORDE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"type": "alert",
"scope": { "kind": "topic", "topic": "cyber" },
"delivery": {
"kind": "webhook",
"url": "https://your.app/exorde-webhook",
"secret": "whsec_..."
}
}'
```
| Quota | Watch | See | Know |
| --------------------- | ----- | --- | ---- |
| Webhook subscriptions | — | 20 | 50 |
Webhook payloads carry the same JSON shape as the corresponding poll endpoint, plus a `delivery_id` for dedup and an HMAC-SHA256 signature in the `X-Exorde-Signature` header for verification. Full setup, signature verification, and dedup pattern: [Alerts → Webhook delivery](/alerts#webhook-delivery-see-and-know).
***
## When to use which
* **Use a curated topic** when your use case aligns with one of the four public slices. Less setup, same analytics, baselines already learned over months of data.
* **Use a watchlist** for anything outside the curated slices — a specific brand, actor, campaign, domain, or niche.
* **Combine both.** A common pattern: poll [`/v1/topics/cyber/alerts`](/alerts) for industry-wide signals **and** maintain a watchlist scoped to your own brand for company-specific monitoring.
* **Know-tier customers** can request **private curated topics** — a topic that behaves like `cyber` or `finance` but is exclusive to your account, with bespoke baselines and audience-tuned [narrative](/narrative) voice. Email [intel@exorde.io](mailto:intel@exorde.io) with the desired scope.
Last reviewed: 2026-05-19. API version 1.2.8.
# Trending
Source: https://docs.exorde.io/trending
Top terms driving conversation on a topic or watchlist, ranked by rolling z-score with a 24-hour delta. The state of the conversation in one call.
The `trending` endpoint returns the **top terms** driving conversation on a topic or watchlist right now, ranked by a rolling z-score against a 14-day baseline. It is the fastest way to answer "what is everyone actually talking about" in one call, and it is available on every tier — including the free Watch trial.
Trending answers a different question from [Alerts](/alerts) and [Narrative](/narrative):
| Surface | Question | Shape |
| ----------------------- | ------------------------------------------ | ------------------------------------------- |
| **Trending** | "Which words are spiking?" | Ranked list of terms with z-scores |
| [Narrative](/narrative) | "What is the story those words form?" | Editorial summary + sub-narrative weights |
| [Alerts](/alerts) | "What just happened that needs attention?" | Discrete events with severity, spread, IOCs |
Use trending as the **state** view — a tile on a dashboard, a feed in a wallboard, a row in an exec digest. Use alerts when you need a push-shaped, machine-routable event.
***
## The endpoints
| Endpoint | Tier | Returns |
| ---------------------------------- | ------ | -------------------------------------------- |
| `GET /v1/topics/{topic}/trending` | Watch+ | Top terms on a curated topic |
| `GET /v1/watchlists/{id}/trending` | See+ | Top terms inside your watchlist's term scope |
Both return the **same JSON shape**. Code written for one works on the other — the only difference is the scope identifier in the response (`topic` vs. `watchlist_id`).
***
## The response envelope
```json theme={null}
{
"topic": "global",
"snapshot_id": "2026-05-19T13:00:00Z",
"terms": [
{ "term": "Iran", "score": 847.2, "rank": 1, "delta_24h": 6.6, "post_count": 12480 },
{ "term": "Eurovision", "score": 412.0, "rank": 2, "delta_24h": 1.8, "post_count": 6210 },
{ "term": "ECB", "score": 289.4, "rank": 3, "delta_24h": 0.9, "post_count": 4055 },
{ "term": "Bitcoin", "score": 211.7, "rank": 4, "delta_24h": -0.3, "post_count": 3142 }
],
"count": 50,
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" },
"query_window": { "hours": 24, "effective_hours": 24 }
}
```
### Field guide
| Field | Type | Meaning |
| ------------------------------------- | ------------ | ------------------------------------------------------------------------ |
| `topic` / `watchlist_id` | string | Scope identifier — exactly one is present |
| `snapshot_id` | ISO-8601 UTC | Pipeline run that produced this response. Pin to it for reproducibility. |
| `terms[]` | array | Ranked term list, length capped per tier (see below) |
| `terms[].term` | string | Surface form, lowercased, deduped against synonyms |
| `terms[].score` | float | Rolling z-score against 14-day baseline. Higher = more anomalous |
| `terms[].rank` | int | 1-indexed position in this snapshot |
| `terms[].delta_24h` | float | Score change vs. same window 24h ago. Positive = accelerating |
| `terms[].post_count` | int | Raw posts mentioning the term in the window |
| `count` | int | Total terms returned (≤ tier cap) |
| `data_freshness.snapshot_age_seconds` | int | Seconds since pipeline produced this snapshot |
| `data_freshness.level` | enum | `ok`, `degraded`, `stale` — UI traffic light |
| `query_window.hours` | int | What you asked for |
| `query_window.effective_hours` | int | What you got after tier clamping |
`score` is **not** comparable across topics — `global` and `cyber` have different baselines. `delta_24h` is comparable across snapshots within the same topic.
***
## Query parameters
| Param | Default | Watch cap | See cap | Know cap | Notes |
| --------------- | ------- | --------- | ------- | -------- | --------------------------------------------------------------------------------------------------- |
| `hours` | 24 | 24 | 72 | 168 | Rolling window length. Above-cap requests are silently clamped — see `query_window.effective_hours` |
| `limit` | 50 | 20 | 100 | 500 | Hard ceiling on returned terms |
| `min_score` | — | — | — | — | Filter out terms below this z-score |
| `exclude_terms` | — | — | — | — | Comma-separated list to suppress (e.g. brand stopwords) |
A request for `limit=10000&hours=720` on a Watch key returns at most 20 terms over 24h, with `query_window.effective_hours: 24` so your UI knows what it actually got.
***
## Calling it
```bash curl theme={null}
curl "https://intel-v1.exorde.io/v1/topics/global/trending?limit=10" \
-H "X-API-Key: $EXORDE_API_KEY"
```
```python Python theme={null}
import os, httpx
r = httpx.get(
"https://intel-v1.exorde.io/v1/topics/global/trending",
params={"limit": 10},
headers={"X-API-Key": os.environ["EXORDE_API_KEY"]},
timeout=10,
).json()
print(f"snapshot {r['snapshot_id']} age={r['data_freshness']['snapshot_age_seconds']}s")
for t in r["terms"]:
print(f"{t['rank']:>2}. {t['term']:<25} z={t['score']:>7.1f} Δ24h={t['delta_24h']:+.1f}")
```
```javascript Node theme={null}
const r = await fetch(
"https://intel-v1.exorde.io/v1/topics/global/trending?limit=10",
{ headers: { "X-API-Key": process.env.EXORDE_API_KEY } }
).then(r => r.json());
r.terms.forEach(t =>
console.log(`${t.rank}. ${t.term} z=${t.score.toFixed(1)} Δ24h=${t.delta_24h}`)
);
```
```powershell PowerShell theme={null}
$r = Invoke-RestMethod `
-Uri "https://intel-v1.exorde.io/v1/topics/global/trending?limit=10" `
-Headers @{ "X-API-Key" = $env:EXORDE_API_KEY }
$r.terms | Select-Object rank, term, score, delta_24h | Format-Table
```
***
## Reading the score
`score` is a **rolling z-score** — how many standard deviations above the 14-day baseline this term's volume sits. Rough guidance:
| Score range | Interpretation |
| ----------- | ---------------------------------------------------------------- |
| `< 50` | Background noise on a high-volume topic |
| `50 – 200` | Normal trending — recurring storyline, scheduled event |
| `200 – 500` | Genuinely elevated — a real story is forming |
| `> 500` | Loud — the term is dominating the snapshot |
| `> 1000` | Rare. Usually maps to a major breaking story or coordinated push |
Combine `score` with `delta_24h` to distinguish **accelerating** from **decaying** stories:
* High `score` + high `delta_24h` → story breaking now
* High `score` + low or negative `delta_24h` → story peaked, decaying
* Low `score` + high `delta_24h` → emerging, worth watching
***
## Trending on a watchlist
Same shape, scoped to your watchlist's terms. Useful when the top terms inside your brand or threat scope diverge from the topic baseline.
```bash theme={null}
curl https://intel-v1.exorde.io/v1/watchlists/wl_01HXYZ.../trending \
-H "X-API-Key: $EXORDE_API_KEY"
```
```json theme={null}
{
"watchlist_id": "wl_01HXYZ7DKMSV2X9WJEQ5RQ4N3F",
"name": "acme-monitoring",
"snapshot_id": "2026-05-19T13:00:00Z",
"terms": [
{ "term": "acme", "score": 142.3, "rank": 1, "delta_24h": 0.4, "post_count": 1820 },
{ "term": "ceo", "score": 87.1, "rank": 2, "delta_24h": 1.2, "post_count": 920 },
{ "term": "earnings", "score": 61.8, "rank": 3, "delta_24h": 3.7, "post_count": 610 }
],
"data_freshness": { "snapshot_age_seconds": 312, "level": "ok" }
}
```
`earnings` accelerating fast (`delta_24h: 3.7`) on a brand watchlist is the kind of signal a comms team wants to see before the quarterly press cycle. See [Topics and watchlists](/topics-and-watchlists#custom-watchlists) for watchlist setup.
***
## Cadence and freshness
Snapshots refresh every few minutes. Polling faster than the snapshot cadence returns the same payload — burns RPM for nothing.
| Use case | Tier | Cadence | Daily call cost |
| ------------------- | ----- | ----------- | --------------- |
| Dashboard tile | Watch | every 5 min | \~290 / day |
| Live wallboard | See | every 60s | \~1,440 / day |
| Real-time exec view | Know | every 15s | \~5,760 / day |
Below 15-second freshness on trending you're chasing snapshot publication latency, not the data — there's nothing newer to fetch. For sub-second event delivery, use [Alerts via webhook](/alerts#webhook-delivery-see-and-know).
Always read `X-RateLimit-Remaining` and back off when it drops below 20% of `X-RateLimit-Limit`. Full pattern: [Rate limits](/rate-limits).
***
## Common patterns
### Filter out predictable churn
Some terms appear permanently elevated on certain topics (`bitcoin` on finance, `russia` on disinfo). Mute them client-side or with `exclude_terms`:
```python theme={null}
EXCLUDE = {"bitcoin", "ethereum", "russia"}
fresh = [t for t in r["terms"] if t["term"].lower() not in EXCLUDE]
```
### Track new entrants
Compare consecutive snapshots and surface terms that just appeared in the top N:
```python theme={null}
prev_terms = {t["term"] for t in prev_snapshot["terms"][:25]}
new_entrants = [t for t in current["terms"][:25] if t["term"] not in prev_terms]
```
A term that wasn't in the top 25 an hour ago and now sits at rank 7 is usually worth a look.
### Cross-topic comparison
Pull trending on `global`, `cyber`, `finance`, `disinfo` in parallel for a one-page situational view. Score is not comparable across topics, but presence and rank are. See [Use cases recipe 5](/use-cases#5-executive-dashboard-single-pane-situational-awareness).
***
## Snapshots and reproducibility
Every response carries `snapshot_id`. Pin to a snapshot when you need to reproduce an analysis or align trending with a specific narrative or alert state. Snapshots are retained for the same window your tier allows on history depth (see [Tiers — history depth caps](/tiers#history-depth-caps)).
```bash theme={null}
# Pin a query to the snapshot the alert was emitted under
curl "https://intel-v1.exorde.io/v1/topics/global/trending?snapshot_id=2026-05-19T13:00:00Z" \
-H "X-API-Key: $EXORDE_API_KEY"
```
A `410 snapshot_expired` envelope means the snapshot rolled out of retention — refetch with `latest`. See [Errors](/errors#resource-errors).
***
## Operational guidance
* **Don't compare `score` across topics.** Different baselines, different volume regimes. `rank` and `delta_24h` are the cross-topic-safe fields.
* **Cache by `snapshot_id`.** Same `snapshot_id` = same payload. Re-fetch only when you suspect a new snapshot exists (every few minutes).
* **Pair trending with [narrative](/narrative)** on dashboards: the term list is the spike, the narrative is the story. One without the other under-delivers.
* **For watchlists, expect lower scores.** A watchlist filters the universe — the top term in your brand scope may have a `score` of 80 while `global` has terms above 800. The math is the same; the volume is smaller.
* **Trending counts against RPM** like any other call. Don't poll under the snapshot cadence.
***
## What's not trending
| You want | Use this |
| -------------------------------- | --------------------------------------------------------------------------------------- |
| "Volume of X over the last week" | [`/v1/topics/{t}/volume`](/topics-and-watchlists#analytics-endpoints-on-curated-topics) |
| "Volume broken down by keyword" | `/v1/topics/{t}/volume/keywords` |
| "Find posts mentioning X" | `/v1/topics/{t}/search` (See+) |
| "Which entities co-occur" | `/v1/topics/{t}/entities/cooccurrence` (See+) |
| "Push me when something spikes" | [Alerts via webhook](/alerts#webhook-delivery-see-and-know) |
Trending is the **ranked-state** view. Everything else is volume, content, or events.
Last reviewed: 2026-05-19. API version 1.2.8.
# Use cases
Source: https://docs.exorde.io/use-cases
Five copy-paste recipes for the most common Exorde Intel API integrations. Newsroom, brand, threat-intel, disinfo, dashboard.
Each recipe below is a working integration, not a sketch. Set `EXORDE_API_KEY`, paste the snippet, run.
The recipes lean on three core signal endpoints — see [Trending](/trending), [Narrative](/narrative), and [Alerts](/alerts) for the full envelopes powering each tile and event.
***
## 1. Newsroom — catch breaking stories before the wires
**Goal:** alert the news desk when a topic spikes outside its normal pattern.
**Tier:** Watch trial sufficient for `global`. See/Know required for `cyber`/`finance`/`disinfo`.
**Endpoints:** `GET /v1/topics/{topic}/alerts`, optional [webhook subscription](/alerts#webhook-delivery-see-and-know).
See [Alerts](/alerts) for the full alert envelope, signal types, IOC schema, and webhook delivery details.
A real signal from this pipeline (2026-05-18, 6.67σ above 14-day baseline, validated by our LLM gate):
> *Multiple credible data breach disclosures (Turkish breach, FoxIT/Foxit software, gaming accounts) surfacing on dark web with fact-checker verification signals genuine cybersecurity incidents being reported and discussed across platforms.*
```python theme={null}
import os, time, httpx
from datetime import datetime, timezone
BASE = "https://intel-v1.exorde.io"
HEADERS = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
SEEN: set[str] = set()
def poll(topic: str = "global", hours: int = 168) -> list[dict]:
r = httpx.get(
f"{BASE}/v1/topics/{topic}/alerts",
params={"hours": hours, "limit": 50, "llm_validated": True},
headers=HEADERS,
timeout=10,
)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", 5)))
return []
r.raise_for_status()
fresh = [a for a in r.json()["alerts"] if a["alert_id"] not in SEEN]
SEEN.update(a["alert_id"] for a in fresh)
return fresh
def slack_card(a: dict) -> dict:
sev = a["severity"]
spread = a["spread"]
text = (
f"*🚨 Volume spike on `{a['topic']}` — keyword `{a['keyword']}`*\n"
f"σ={sev['deviation_sigma']:.2f} "
f"current={sev['current_value']:.0f} "
f"baseline={sev['baseline_value']:.1f}\n"
f"Spread: {spread['domain_count']} domains, "
f"{spread['language_count']} languages\n\n"
f"_{a['description']}_"
)
return {
"text": f"*{a['keyword']}* — {a['description']}",
"blocks": [
{"type": "section", "text": {"type": "mrkdwn", "text": text}}
],
}
while True:
for a in poll("global"):
ts = datetime.now(timezone.utc).strftime("%H:%M:%S")
print(f"[{ts}] {a['keyword']:<25} σ={a['severity']['deviation_sigma']:.2f}")
# httpx.post(SLACK_WEBHOOK_URL, json=slack_card(a))
time.sleep(60)
```
**Cost:** 1,440 calls/day per topic. Comfortably inside Watch's 5,000-call monthly budget for one topic; trivial on See's 250,000 monthly budget across multiple topics.
**Upgrade path:** swap polling for a [webhook subscription](/alerts#webhook-delivery-see-and-know) (See/Know) to get sub-second push delivery into Slack/Teams/PagerDuty without burning RPM.
***
## 2. Brand monitoring with watchlists
**Goal:** track every public mention of your brand, your domains, and your executives — across the whole conversation, not just one platform.
**Tier:** See or Know.
**Endpoints:** `POST /v1/watchlists`, `GET /v1/watchlists/{id}/(trending|entities|platforms|posts|alerts)`. See [Topics and watchlists](/topics-and-watchlists#custom-watchlists) for the full term-type reference.
```python theme={null}
import os, httpx
BASE = "https://intel-v1.exorde.io"
KEY = os.environ["EXORDE_API_KEY"]
HEADERS_JSON = {"X-API-Key": KEY, "Content-Type": "application/json"}
HEADERS_GET = {"X-API-Key": KEY}
# 1. Create the watchlist (one-time)
r = httpx.post(
f"{BASE}/v1/watchlists",
headers=HEADERS_JSON,
json={
"name": "acme-brand",
"base_topic": "global",
"terms": [
{"type": "keyword", "value": "acme"},
{"type": "keyword", "value": "acme corp"},
{"type": "domain", "value": "acme.com"},
{"type": "entity", "value": "jane doe"}, # CEO
{"type": "entity", "value": "john roe"}, # CFO
],
},
timeout=10,
)
r.raise_for_status()
wl_id = r.json()["id"]
print("created:", wl_id)
# 2. Daily snapshot — what's spiking, who's mentioned, where
def daily_snapshot(wl_id: str) -> None:
def g(path: str) -> dict:
return httpx.get(
f"{BASE}/v1/watchlists/{wl_id}{path}",
headers=HEADERS_GET,
timeout=10,
).json()
print("Top terms: ", [t["term"] for t in g("/trending")["terms"][:5]])
print("Top entities: ", [e["entity"] for e in g("/entities")["entities"][:5]])
print("Top platforms:", [p["platform"] for p in g("/platforms")["platforms"][:5]])
alerts = g("/alerts?hours=24")["alerts"]
if alerts:
print(f"!! {len(alerts)} brand alerts in last 24h")
daily_snapshot(wl_id)
```
**Why watchlists beat keyword-only search:** the Exorde pipeline already classifies entities and clusters posts by narrative. A watchlist runs the same pipeline filtered to your terms, so you get **structured** signal — entity leaderboards, platform breakdowns, narrative summaries — not a keyword feed.
***
## 3. Threat-intel desk — daily cyber alert digest
**Goal:** every morning, the analyst sees yesterday's high-σ cyber events with IOCs extracted, sample posts, and matched cluster context.
**Tier:** See.
**Endpoints:** `GET /v1/topics/cyber/alerts`, `GET /v1/topics/cyber/clusters/{id}` for drill-down. Full alert envelope including the IOC schema lives in [Alerts](/alerts#iocs).
```python theme={null}
import os, httpx
from datetime import datetime, timezone
BASE = "https://intel-v1.exorde.io"
H = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
def cyber_morning_digest() -> None:
r = httpx.get(
f"{BASE}/v1/topics/cyber/alerts",
params={"hours": 24, "limit": 50, "llm_validated": True},
headers=H,
timeout=10,
).json()
if not r["alerts"]:
print("no qualifying cyber alerts in last 24h")
return
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
print(f"=== Cyber digest, {today} ===")
for a in sorted(r["alerts"], key=lambda x: -x["severity"]["deviation_sigma"]):
sev = a["severity"]
iocs = a["iocs"]
ioc_summary: list[str] = []
for k in ("cves", "domains", "ips", "urls", "crypto_wallets"):
if iocs.get(k):
ioc_summary.append(f"{len(iocs[k])} {k}")
for h_alg in ("md5", "sha1", "sha256"):
if iocs["hashes"].get(h_alg):
ioc_summary.append(f"{len(iocs['hashes'][h_alg])} {h_alg}")
print(f"\n[{a['detected_at']}] σ={sev['deviation_sigma']:.2f} '{a['keyword']}'")
print(f" {a['description']}")
if a.get("matched_cluster"):
print(f" cluster: {a['matched_cluster']['cluster_title']}")
if ioc_summary:
print(f" IOCs: {', '.join(ioc_summary)}")
for cve in iocs.get("cves", [])[:3]:
print(f" - {cve}")
cyber_morning_digest()
```
**Sample output**, real run from 2026-05-18:
Cyber digest, 2026-05-19
\[2026-05-18T04:00:30.148Z] σ=6.67 'dark web'
Multiple credible data breach disclosures (Turkish breach, FoxIT/Foxit software,
gaming accounts) surfacing on dark web with fact-checker verification...
cluster: Dark-web breach disclosures, May 2026
**Drill-down:** for any alert with a `matched_cluster.cluster_id`, fetch the full cluster context with `GET /v1/topics/cyber/clusters/{id}` (See tier) — top entities, top domains, time-series, full evidence post list.
***
## 4. Disinformation early-warning
**Goal:** catch coordinated narrative pushes before they reach mainstream amplification — multi-platform, multi-language synchronisation, LLM-validated.
**Tier:** See or Know.
**Endpoints:** `GET /v1/topics/disinfo/alerts`, [`GET /v1/topics/disinfo/narratives/history`](/narrative#historical-narratives-stable-surface), `GET /v1/topics/disinfo/platforms`.
```python theme={null}
import os, httpx
BASE = "https://intel-v1.exorde.io"
H = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
# 1. High-confidence disinfo alerts — LLM-validated, multi-language, multi-domain
r = httpx.get(
f"{BASE}/v1/topics/disinfo/alerts",
params={"hours": 168, "limit": 25},
headers=H,
timeout=10,
).json()
suspicious = [
a for a in r["alerts"]
if a.get("llm_validated")
and a["spread"]["language_count"] >= 3
and a["spread"]["domain_count"] >= 5
and a["confidence"] >= 0.7
]
print(f"{len(suspicious)} high-confidence multi-platform disinfo signals")
# 2. Cross-reference with cluster context where available
for a in suspicious[:5]:
sev = a["severity"]
spread = a["spread"]
print(f"\n- {a['keyword']} σ={sev['deviation_sigma']:.1f}")
print(f" spread: {spread['domain_count']} domains × {spread['language_count']} langs")
print(f" context: {a['description'][:200]}")
if a.get("matched_cluster"):
print(f" cluster: {a['matched_cluster']['cluster_title']}")
```
**Operational tip:** combine the disinfo signal with [`/narratives/history`](/narrative#historical-narratives-stable-surface) (See tier) to see whether the alert is part of a longer-running narrative pivot or a sudden burst. A campaign that's been smouldering for two weeks behaves very differently from one that's a 6-hour burst.
***
## 5. Executive dashboard — single-pane situational awareness
**Goal:** one page that shows, for every curated topic: latest narrative, top 3 trending terms, alert count last 24h, freshness.
**Tier:** See.
**Endpoints:** `GET /v1/topics/{t}/(narrative|trending|alerts)` × 4 topics.
See [Trending](/trending) and [Narrative](/narrative) for the full response envelopes powering each tile.
```python theme={null}
import os, asyncio, httpx
from datetime import datetime, timezone
BASE = "https://intel-v1.exorde.io"
H = {"X-API-Key": os.environ["EXORDE_API_KEY"]}
TOPICS = ["global", "cyber", "finance", "disinfo"]
async def fetch(client: httpx.AsyncClient, topic: str) -> dict:
n, t, a = await asyncio.gather(
client.get(f"{BASE}/v1/topics/{topic}/narrative"),
client.get(f"{BASE}/v1/topics/{topic}/trending"),
client.get(f"{BASE}/v1/topics/{topic}/alerts?hours=24&limit=50"),
)
n_data, t_data, a_data = n.json(), t.json(), a.json()
return {
"topic": topic,
"narrative": n_data.get("summary", ""),
"top_terms": [x["term"] for x in t_data.get("terms", [])[:3]],
"alert_count": a_data.get("count", len(a_data.get("alerts", []))),
"freshness_seconds": n_data.get("data_freshness", {}).get("snapshot_age_seconds"),
}
async def main() -> None:
async with httpx.AsyncClient(headers=H, timeout=10) as client:
rows = await asyncio.gather(*[fetch(client, t) for t in TOPICS])
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
print(f"\n=== Exorde Intel — {now} ===\n")
for row in rows:
age_min = (row["freshness_seconds"] or 0) // 60
print(f"## {row['topic'].upper()} "
f"(snapshot {age_min}min ago, {row['alert_count']} alerts/24h)")
print(f" trending: {', '.join(row['top_terms'])}")
print(f" {row['narrative'][:240]}\n")
asyncio.run(main())
```
**Cost:** 12 API calls per refresh (3 endpoints × 4 topics). Refresh every minute = 12 calls/minute, comfortably inside See's 120 RPM (10% utilisation) and a rounding error against See's 250,000 monthly quota.
***
## What to build next
| Pattern | Endpoints | Value |
| --------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| **Backfill into a warehouse** | `/posts` paginated, [`/narratives/history`](/narrative#historical-narratives-stable-surface) | Train your own models on the curated post stream |
| **Custom topic for an enterprise** | Email [intel@exorde.io](mailto:intel@exorde.io) with the desired scope | Know-tier private topic with bespoke baselines and tailored [narrative voice](/narrative#roadmap-and-how-to-influence-it) |
| **Multi-tenant SaaS layered on Exorde** | One paid Know key, watchlist-per-tenant, your own quota plane | Resell intelligence to N customers from one contract |
| **Design partner — narrative voice** | [Narrative](/narrative) is in alpha; tell us what shape your ideal output takes | Direct influence on the v1 contract, early access to tailoring features |
Reach out if you have a use case that doesn't map cleanly onto these — most of our paid contracts started as a "can you do X" email.
Last reviewed: 2026-05-19. API version 1.2.8. All snippets tested against production.