API reference
Base URL: https://app.clearexchange.ai for the public demo (https://clear-exchange.fly.dev also works; see Hostnames), http://127.0.0.1:8080 locally (npm start).
All API requests and responses are JSON (content-type: application/json; charset=utf-8, cache-control: no-store). Request bodies are limited to 1 MB. There is no API versioning header; the version is in the path (/v1).
The examples on this page were captured from a local server (npm start, seeded catalogue only). Ids, timestamps and keys will differ on your run. Long arrays are trimmed where marked.
- Conventions
- The agent flow: query, accept, retrieve, report telemetry
- Verification: verify, JWKS
- Discovery and health: supply, coverage, healthz
- Operator endpoints
- Web UI routes
- Errors
Conventions
Money. Every amount appears in two forms: amount (a decimal string) and amount_micros (an integer, 1 unit = 1,000,000 micros), plus an ISO 4217 currency. Use amount_micros for arithmetic. In requests, max_price.amount is a decimal string with at most 6 decimal places.
Prices are all-in. An offer's price is what the agent pays: the publisher's price plus the clearing fee on top. max_price is compared with that all-in price. price_breakdown shows the parts.
Usage tiers, narrowest to broadest: grounding_only (load into the model's context, no verbatim display), display_snippet (grounding plus a short excerpt up to max_snippet_chars), full_text (grounding plus display of the full text). A broader tier includes the rights of the narrower ones.
Agent identity. agent_id is self-declared in the MVP. Offers can be accepted only by the agent_id that queried, and entitlements are issued to it (sub). Authenticated agent identity is on the roadmap.
Authentication.
| Endpoints | Auth |
|---|---|
POST /v1/query, POST /v1/offers/{id}/accept, POST /v1/entitlements/verify, GET /v1/supply, GET /v1/coverage, GET /.well-known/jwks.json, GET /healthz | None |
GET /v1/content/{id}, POST /v1/telemetry | Authorization: Bearer <entitlement> |
GET /v1/admin/* | Authorization: Bearer <ADMIN_TOKEN> when ADMIN_TOKEN is set (always set in production) |
State. Offers and sessions are held in process memory. They are lost on restart, and an offer can be accepted only on the instance that made it. See Configuration and deploy.
The agent flow
POST /v1/query ──▶ offers (ranked, priced, held for OFFER_TTL_SECONDS, default 300 s)
POST /v1/offers/{offer_id}/accept ──▶ signed entitlement (valid ENTITLEMENT_TTL_SECONDS, default 900 s)
GET /v1/content/{content_id} + Bearer entitlement ──▶ licensed content; ledger entry; content_retrieved event
POST /v1/telemetry + Bearer entitlement ──▶ grounded / cited / presented / engaged events
POST /v1/query
Ask a question. The exchange routes it to a few sources, re-scores and prices every candidate, and returns ranked offers. See Architecture for how ranking works.
Request:
{
"query": "Do heat pumps work in cold climates?",
"agent_id": "my-agent",
"max_price": { "amount": "0.010", "currency": "USD" },
"usage": "display_snippet",
"limit": 2
}
| Field | Required | Meaning |
|---|---|---|
query | yes | Natural-language question, 1 to 1000 characters. |
agent_id | yes | The agent's identifier (self-declared). |
max_price | yes | { "amount": "<decimal>", "currency": "<ISO 4217>" }. The most the agent will pay per item, all-in. A constraint, not a sort key: offers above it are excluded. Offers in another currency are excluded (no conversion). |
usage | no | Usage tier needed. Default display_snippet. An offer may sell a broader tier if that is the cheapest tier granting the requested one. |
session_id | no | UUID of an existing session to continue. If omitted, a new session is created. A session that belongs to another agent returns 403 session_forbidden. |
limit | no | Offers to return, 1 to 50. Default 10. The selected offer is always returned even if limit would cut it. |
constraints.min_relevance | no | Number in [0, 1]. Raises the relevance floor for this query. The exchange's own floor (BAND_FLOOR, default 0.5) always applies, so a lower value has no effect. |
constraints.publishers | no | Only these publisher_ids. |
constraints.exclude_publishers | no | Never these publisher_ids. |
constraints.sections | no | Only content in these sections. |
constraints.published_after | no | ISO date-time. Only content published at or after it (or, for items with no published date, updated at or after it). |
constraints.citation_required | no | false keeps only content whose licence does not require citation. |
Response 200 (one offer shown in full):
{
"query_id": "qry_4ca5c931-f003-4b23-8817-331679929802",
"result": "offers",
"session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e",
"session_started_at": "2026-10-28T10:51:10.370Z",
"relevance_model": "bm25-v1",
"max_price": { "amount": "0.01", "amount_micros": 10000, "currency": "USD" },
"usage": "display_snippet",
"offers": [
{
"offer_id": "off_0f0ef321-f551-42cc-bb22-cf95b7f6be9b",
"rank": 1,
"relevance": 0.6245,
"content_id": "fn-2026-0153",
"publisher": { "publisher_id": "pub_fieldnote", "name": "Fieldnote Science" },
"title": "Do heat pumps work in cold climates? What the field data shows",
"snippet": "Monitoring data from more than 2,000 homes shows modern air-source heat pumps keep working well below freezing. ...",
"url": "https://fieldnote.example/energy/2026/04/heat-pumps-cold-climates-field-data",
"section": "energy",
"published_at": "2026-04-09T12:00:00Z",
"freshness": {
"published_at": "2026-04-09T12:00:00Z",
"as_of": "2026-04-09T12:00:00.000Z",
"basis": "published_at",
"age_days": 202,
"status": "no_intent",
"adjustment": 0
},
"price": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"price_breakdown": {
"publisher": { "amount": "0.005", "amount_micros": 5000, "currency": "USD" },
"fee": { "amount": "0.0005", "amount_micros": 500, "currency": "USD" },
"fee_bps": 1000,
"all_in": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"card": { "amount": "0.005", "amount_micros": 5000, "currency": "USD" },
"discounted": false
},
"licence": {
"usage": "display_snippet",
"terms": {
"citation_required": true,
"attribution": "Fieldnote Science",
"max_snippet_chars": 400,
"retention_seconds": 604800,
"ai_training": "prohibited"
},
"rate_card_ref": "rc_fieldnote@v1",
"applied_rules": []
},
"supply_id": "local-catalogue",
"content_hash": "sha256:a1c66a16f0983ea78a692d54e809369efe2dfbf15dff8bfe3b506a4eb7f6dc7d",
"scoring": {
"exchange": 0.6245,
"self_score": null,
"self_score_adjustment": 0,
"overclaim": 0,
"divergence_penalty": 0,
"prior": 0.5,
"prior_adjustment": 0,
"recency_adjustment": 0
},
"in_band": true,
"selected": true,
"expires_at": "2026-10-28T10:56:10.370Z"
}
],
"no_match": null,
"recency": null,
"stats": {
"candidates": 16,
"filtered_by_constraints": 0,
"below_min_relevance": 15,
"below_band": 0,
"unpriceable": 0,
"currency_mismatch": 0,
"over_budget": 0,
"matched": 1,
"returned": 1,
"offers_above_card": 0,
"supply_errors": []
},
"routing": {
"cache": "miss",
"normalised_query": "do heat pumps work in cold climates",
"budget_ms": 1500,
"elapsed_ms": 3,
"fanout_cap": 5,
"explore_slots": 1,
"sources_considered": 1,
"sources": [
{
"source_id": "local-catalogue",
"selected": true,
"reason": "all",
"score": 0.2792,
"profile_score": 0.2792,
"prior": 0.5,
"status": "ok",
"tmax_ms": 1397,
"latency_ms": 0,
"candidates": 16
}
]
},
"selection": {
"rule": "cheapest_in_band",
"band": { "ratio": 0.9, "floor": 0.5, "top_relevance": 0.6245, "threshold": 0.5621 },
"in_band": 1,
"selected_offer_id": "off_0f0ef321-f551-42cc-bb22-cf95b7f6be9b",
"reason": "cheapest_in_band"
},
"price_hint": null
}
Offer fields worth knowing:
| Field | Meaning |
|---|---|
offer_id | Pass to accept. Valid until expires_at, for this agent_id only. |
relevance | The exchange's score in [0, 1] after bounded adjustments. Offers are sorted by it; price breaks exact ties only. |
scoring | How relevance was built: the exchange's own score (exchange), the source's claimed self_score and its capped adjustment, overclaim penalty, telemetry prior and recency_adjustment (see Recency and freshness). |
published_at, updated_at | When the item was first published and last updated, when the source says. Either may be absent. |
freshness | as_of (the newer of the two dates), basis (which one), age_days (whole days old, null if undated), status and adjustment (what recency added to or took from relevance). Present on every offer. |
price, price_breakdown | All-in price; publisher price, fee, card price and whether the source discounted below its card. |
licence | The usage tier that would be sold, its terms, the rate card reference (id@vN) and any price rules applied. |
content_licence | Present for openly licensed items (for example Wikipedia): licence id, name, URL, share_alike, who to credit. |
content_hash | sha256:<hex> of the full text, when the source declares it. Bound into the entitlement and checked on retrieval. |
in_band, selected | Whether the offer is in the relevance band, and whether it is the selected (cheapest in-band) offer. At most one offer is selected. |
Response-level fields:
| Field | Meaning |
|---|---|
result | "offers" when at least one offer cleared the floor and band, otherwise "no_good_match". Both are 200. |
no_match | null when there are offers. Otherwise why not (see No good match). |
recency | null unless the query asks for a period. Then kind (window or year), the phrase that set it, a plain-English description, and window_days or year. |
session_id, session_started_at | Put these on SPUR telemetry batches (session_id, started_at). |
stats | Counts of candidates and why they were dropped: below_min_relevance (under the floor), below_band (over the floor but under ratio × the top), over_budget, and so on. supply_errors lists sources that failed or timed out; offers from the others are still returned. |
routing | Which sources were asked and why (reason: all, match or explore; null if not routed), how each answered (status: ok, late, error, skipped or not_routed), and whether the candidate pool came from cache. |
selection | The band (threshold = max(ratio × top, floor)) and the selected offer. reason is cheapest_in_band, no_offers or below_floor (top relevance under the floor, so nothing is selected). |
price_hint | null unless relevant offers were excluded only because of price. Then it gives the price and relevance of the cheapest and the most relevant excluded offer, so the agent can raise its budget to a known figure or move on. It carries no ids and holds nothing. |
Relevance floor and near-top band
Every candidate is re-scored on one scale, then only those with relevance ≥ BAND_FLOOR (default 0.5, or the query's higher min_relevance) and ≥ BAND_RATIO (default 0.9) × the top candidate are offered. The top is measured before max_price is applied, so a cheap second-best item does not become "the best" because the best one is over budget. Off-topic items never appear, however cheap.
No good match
When nothing clears the floor and band, the exchange says so instead of offering low-quality content. The response is still 200, with "result": "no_good_match", "offers": [] and a no_match object:
{
"result": "no_good_match",
"offers": [],
"no_match": {
"reason": "below_floor",
"message": "No good match: nothing the sources returned was relevant enough to offer. Rephrase the query or try elsewhere.",
"floor": 0.5,
"best_relevance": 0.3521
},
"selection": { "rule": "cheapest_in_band", "band": { "ratio": 0.9, "floor": 0.5, "top_relevance": null, "threshold": null }, "in_band": 0, "selected_offer_id": null, "reason": "no_offers" },
"price_hint": null
}
no_match.reason | Meaning | What to do |
|---|---|---|
over_budget | Relevant content exists, but none within max_price. price_hint says what would clear. | Raise max_price to the hinted figure, or move on. |
not_offered | Relevant content exists, but not for this usage tier or currency. | Try another usage tier. |
below_floor | Candidates came back, but none was relevant enough. best_relevance is the best score reached. | Rephrase, or look elsewhere. |
filtered_by_constraints | Your constraints (publishers, sections, dates, citation) excluded every candidate. | Loosen the constraints. |
no_candidates | No source returned anything (the message says if sources failed to answer in time). | Try again later or rephrase. |
Reasons are checked in that order, so the most actionable one wins. message is plain English an agent can show its user.
Recency and freshness
If the query asks for a period, the exchange boosts items inside it and demotes stale and undated ones. It recognises:
| Phrase in the query | Period |
|---|---|
| "today", "tonight", "right now", "breaking", "this morning" | last 2 days |
| "yesterday" | last 3 days |
| "this week", "past week", "past 7 days" / "last week" | last 7 / 14 days |
| "latest", "newest", "most recent", "recent", "recently", "up to date" | last 30 days |
| "this month", "past month", "past 30 days" / "last month" | last 31 / 62 days |
| "past year", "past 12 months" | last 366 days |
| "this year", "last year", or a year such as "2009" | that calendar year |
The tightest window phrase wins. Window phrases are removed from the text the relevance scorer sees, because they say when, not what; years stay in.
For a window, an item published inside it gains up to RECENCY_BOOST (0.1, more the fresher it is); an item whose newest date is outside it loses up to RECENCY_PENALTY (0.3, growing with how far outside); an undated item loses RECENCY_UNDATED_PENALTY (0.1). An update inside the window protects an older item from the penalty but earns no boost. For a year, items published that year gain the boost, items published before it are demoted, and items published after it (retrospectives) are left alone.
freshness.status says which applied: fresh, updated, stale (window); in_period, before_period, after_period (year); undated; or no_intent when the query asked for no period. The adjustment can push an item under the floor, so a "this month" query with only stale results answers no_good_match rather than offer them.
POST /v1/offers/{offer_id}/accept
Accept an offer and receive a signed entitlement. One entitlement per offer.
Request:
{ "agent_id": "my-agent" }
Response 201:
{
"entitlement": "eyJhbGciOiJFZERTQSIsInR5cCI6ImN4LWVudGl0bGVtZW50K2p3dCIsImtpZCI6ImN4LU1oY1QwLTlOczBZY2NkMmkifQ.eyJpc3Mi...fQ.5XPMJWC0...gT0eqBg",
"entitlement_id": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"expires_at": "2026-10-28T11:06:17.000Z",
"content_id": "fn-2026-0153",
"usage": "display_snippet",
"price": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"price_breakdown": {
"publisher": { "amount": "0.005", "amount_micros": 5000, "currency": "USD" },
"fee": { "amount": "0.0005", "amount_micros": 500, "currency": "USD" },
"fee_bps": 1000,
"all_in": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"card": { "amount": "0.005", "amount_micros": 5000, "currency": "USD" },
"discounted": false
},
"licence": {
"usage": "display_snippet",
"terms": {
"citation_required": true,
"attribution": "Fieldnote Science",
"max_snippet_chars": 400,
"retention_seconds": 604800,
"ai_training": "prohibited"
},
"rate_card_ref": "rc_fieldnote@v1",
"applied_rules": []
},
"session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e",
"session_started_at": "2026-10-28T10:51:10.370Z",
"retrieve_url": "/v1/content/fn-2026-0153",
"telemetry_url": "/v1/telemetry"
}
The response repeats the signed terms so agents need not decode the token. The token format is in Entitlements.
Errors: 400 invalid_request (no agent_id), 404 offer_not_found (unknown or expired), 403 offer_forbidden (another agent's offer), 409 offer_already_accepted (with details.entitlement_id).
GET /v1/content/{content_id}
Retrieve licensed content.
curl -s localhost:8080/v1/content/fn-2026-0153 \
-H "Authorization: Bearer $ENTITLEMENT" \
-H "Content-Telemetry-ID: 3f2b8c1e-9a4d-4e7b-8c2a-1d5e6f7a8b9c"
| Header | Required | Meaning |
|---|---|---|
Authorization: Bearer <entitlement> | yes | Must be valid, unexpired and for this content_id. |
Content-Telemetry-ID | no | The agent's correlation id. Echoed back in the response header; recorded on the content_retrieved event if it is a UUID, and forwarded to remote sources. |
Response 200:
{
"content_id": "fn-2026-0153",
"title": "Do heat pumps work in cold climates? What the field data shows",
"url": "https://fieldnote.example/energy/2026/04/heat-pumps-cold-climates-field-data",
"published_at": "2026-04-09T12:00:00Z",
"media_type": "text",
"text": "Monitoring data from more than 2,000 homes in cold regions shows ...",
"licence": {
"entitlement_id": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"usage": "display_snippet",
"display": { "allowed": true, "max_chars": 400 },
"citation_required": true,
"attribution": "Fieldnote Science",
"retain_until": "2026-11-04T10:51:17.501Z",
"ai_training": "prohibited"
},
"clearing": {
"ledger_entry_id": "led_6f8427f3-a75a-40d8-8254-3774c18b17c4",
"gross": "0.0055",
"publisher": "0.005",
"fee": "0.0005",
"fee_bps": 1000,
"currency": "USD"
},
"telemetry_event_id": "2dc312b9-38e9-4beb-9738-4f2135654744"
}
- The full text is always returned;
licence.displaysays how much of it the agent may show (grounding_only:allowed: false;display_snippet: up tomax_chars;full_text: no limit).retain_untilis now plus the licence'sretention_seconds. - Openly licensed items (Wikinews, Wikipedia) add
licence.content_licenceand anattributionblock with a ready-made credit line (attribution.text), source and revision links, the licence and achangesnote. See Live sources. clearingis the ledger entry, ornullfor zero-price content. The ledger is idempotent per entitlement: retrieving again with the same entitlement is not charged again. Each retrieval does emit a newcontent_retrievedevent.
Errors: 401 entitlement_required (no token), 401 entitlement_<reason> for an invalid token (see Errors), 403 entitlement_wrong_content, 404 content_not_found, 502 supply_unavailable (the source failed; nothing is recorded, so retry with the same entitlement), 502 supply_content_mismatch (the source's text does not match the offered content_hash; the agent is not charged).
POST /v1/telemetry
Report what the agent did with the content, as a SPUR Content Telemetry v1.0 event_batch, authorised by the entitlement. Field rules are in Telemetry.
curl -s localhost:8080/v1/telemetry -H "Authorization: Bearer $ENTITLEMENT" -d @batch.json
Request:
{
"document_type": "event_batch",
"schema_version": "1.0",
"session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e",
"agent_id": "my-agent",
"started_at": "2026-10-28T10:51:10.370Z",
"events": [
{ "id": "6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e60", "type": "content_grounded", "timestamp": "2026-10-28T10:51:18Z",
"data": { "scope": "turn", "provenance": "agent_fetched", "chars_ingested": 1840 } },
{ "id": "6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e61", "type": "content_cited", "timestamp": "2026-10-28T10:51:19Z",
"output_id": "out-1", "data": { "citation_type": "paraphrase", "position": "primary", "excerpt_chars": 220 } },
{ "id": "6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e62", "type": "content_presented", "timestamp": "2026-10-28T10:51:19Z",
"output_id": "out-1", "data": { "presentation_kind": "source_reference", "presentation_type": "inline_link" } },
{ "id": "6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e63", "type": "content_engaged", "timestamp": "2026-10-28T10:52:02Z",
"presentation_id": "6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e62" }
]
}
Response 202:
{
"accepted": 4,
"event_ids": [
"6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e60",
"6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e61",
"6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e62",
"6b1d6c2e-0d7a-4c55-9a7e-2b8f3c4d5e63"
],
"duplicate_event_ids": [],
"warnings": []
}
Sending the same batch again returns 202 with accepted: 0 and every id in duplicate_event_ids. warnings lists licence breaches the exchange noticed (for example terms.display_not_licensed); they do not reject the batch.
The entitlement is accepted for TELEMETRY_GRACE_SECONDS (default 7 days) after it expires, because later stages can happen after the retrieval window.
Errors: the batch is atomic, so any error rejects all of it. 422 telemetry_invalid for structural problems, 403 telemetry_forbidden when the batch does not match the entitlement (agent, content or license_ref). details lists every problem:
{
"error": {
"code": "telemetry_forbidden",
"message": "telemetry batch rejected",
"details": [
{ "path": "agent_id", "code": "forbidden", "message": "agent_id does not match the entitlement holder" },
{ "path": "events[0].id", "code": "required", "message": "id is required on content_cited" },
{ "path": "events[0].output_id", "code": "required", "message": "output_id is required on content_cited" }
]
}
}
Verification
POST /v1/entitlements/verify
Check a token, optionally for a specific content_id and the usage tier you need. Useful for publishers and tools that do not want to verify JWS themselves; to verify offline, use the JWKS.
Request:
{ "token": "eyJhbGciOiJFZERTQSIs...", "content_id": "fn-2026-0153", "usage": "display_snippet" }
Response 200, valid:
{
"valid": true,
"claims": {
"iss": "http://localhost:8080",
"sub": "my-agent",
"aud": "http://localhost:8080",
"jti": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"iat": 1793184677,
"nbf": 1793184677,
"exp": 1793185577,
"cx": { "v": 1, "content_id": "fn-2026-0153", "usage": "display_snippet", "...": "see entitlements.md" }
}
}
Response 200, invalid (the HTTP status is still 200; check valid):
{ "valid": false, "error": "insufficient_usage", "message": "token grants display_snippet, not full_text" }
error is one of malformed, unsupported_alg, unknown_key, bad_signature, wrong_issuer, wrong_audience, not_yet_valid, expired, wrong_content, insufficient_usage. A request without token, or with a non-string content_id or unknown usage, returns 400 invalid_request.
GET /.well-known/jwks.json
Public signing keys for offline verification.
{
"keys": [
{ "kty": "OKP", "crv": "Ed25519", "x": "MhcT0-9Ns0Yccd2i9qZY3b7K9seimVEbTKlataWnrqM", "kid": "cx-MhcT0-9Ns0Yccd2i", "alg": "EdDSA", "use": "sig" }
]
}
There is one key today. See Entitlements.
Discovery and health
GET /v1/supply
Registered sources, their status and what routing knows about them.
{
"adapters": [
{
"id": "local-catalogue",
"kind": "local_catalogue",
"status": "live",
"routing": {
"profile": { "topics": ["business", "markets", "shipping", "..."], "samples": 16, "min_score": 0 },
"stats": {
"routed": 1, "explored": 0, "responded": 1, "errors": 0, "late": 0, "skipped": 0,
"candidates": 16, "latency_ms_ewma": 0, "claims": 0, "overclaim": 0, "retrievals": 1,
"stages": { "grounded": 1, "cited": 1, "presented": 1, "engaged": 1 },
"prior": 0.5455
}
}
},
{ "id": "tollbit", "kind": "marketplace", "status": "stub", "routing": { "...": "..." } },
{ "id": "prebid-trusted-server", "kind": "prebid_trusted_server", "status": "stub", "routing": { "...": "..." } }
]
}
kindislocal_catalogue,publisher_api,marketplaceorprebid_trusted_server.statusisliveorstub(stubs return nothing and are never routed to).- Remote sources (Wikimedia,
http-offerpublishers) also reportsource(name, URL, publisher, licence, note) andhealth:state(idle,ok,degraded,down), last success and error, consecutive failures, circuit state, timeout and cache counters. stats.prioris the source's telemetry prior: the share of SPUR stages reached per paid retrieval, smoothed toward 0.5.
GET /v1/coverage
What the exchange can answer from: verticals, publishers, item counts, date ranges and base prices. No auth. Demo supply is always flagged. The same data is shown as a page at /coverage.
{
"demo": true,
"notice": "Demo supply. Publishers marked demo are fictional, ...",
"totals": { "verticals": 3, "publishers": 18, "items": 316 },
"verticals": [
{
"vertical_id": "finance",
"name": "Finance and business news",
"description": "Wire-style business news, markets analysis, corporate strategy and personal finance.",
"demo": true,
"notice": "Demo supply: every publisher in this vertical is fictional ...",
"topics": ["business", "finance", "markets", "..."],
"items": 100,
"publishers": [
{
"publisher_id": "pub_ledgerwick_wire",
"name": "Ledgerwick Wire",
"domain": "ledgerwick.example",
"description": "Wire service: short, fast business and economic news, filed through the day.",
"items": 25,
"sections": ["companies", "deals", "economy", "markets"],
"first_published": "2024-09-12T07:00:00Z",
"last_published": "2026-10-27T08:42:00Z",
"rate_card_id": "rc_ledgerwick_wire",
"currency": "USD",
"base_prices_micros": { "grounding_only": 3000, "display_snippet": 7000, "full_text": 18000 },
"rules": ["breaking", "archive"],
"demo": true,
"supply_id": "vertical-finance"
}
]
}
],
"other_sources": [
{ "supply_id": "local-catalogue", "role": "catalogue", "publishers": [ { "publisher_id": "pub_harbour_ledger", "items": 5, "demo": true, "...": "..." } ] },
{ "supply_id": "wikipedia-en", "role": "fallback", "publishers": [ { "publisher_id": "pub_wikipedia_en", "items": null, "demo": false, "licence": { "id": "CC-BY-SA-4.0", "...": "..." } } ] }
]
}
demois true if any listed publisher is fictional; each publisher has its owndemoflag.itemsis null for open-ended sources searched live (Wikipedia, Wikinews) and for publisher endpoints that do not report a count.base_prices_microsare the rate card's tier prices before rules;rulesnames the adjustments (freshness, section, licence) that can change an offer's price. Offers carry the exact price and the rules applied.roleiscatalogue(the seeded general catalogue),fallback(live open sources) orpublisher(publisher endpoints). Stubs are not listed. See Demo supply.
GET /healthz
Liveness, used by Fly's health check. No auth. Returns 200 {"status": "ok"}.
Operator endpoints (/v1/admin/*)
Read-only views for operators and the web UI. When ADMIN_TOKEN is set, send Authorization: Bearer <ADMIN_TOKEN>; otherwise 401 admin_required. In production ADMIN_TOKEN is mandatory, so these are always protected on the public demo. One shared token gives read access to all operator data; there are no per-publisher views yet.
GET /v1/admin/ledger
{
"fee_bps": 1000,
"fee_basis": "on_top",
"totals": [
{ "publisher_id": "pub_fieldnote", "currency": "USD", "retrievals": 1, "gross_micros": 5500, "fee_micros": 500, "net_micros": 5000 }
],
"entries": [
{
"entry_id": "led_6f8427f3-a75a-40d8-8254-3774c18b17c4",
"recorded_at": "2026-10-28T10:51:17.501Z",
"entitlement_id": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"offer_id": "off_0f0ef321-f551-42cc-bb22-cf95b7f6be9b",
"agent_id": "my-agent",
"publisher_id": "pub_fieldnote",
"supply_id": "local-catalogue",
"content_id": "fn-2026-0153",
"usage": "display_snippet",
"rate_card_ref": "rc_fieldnote@v1",
"currency": "USD",
"gross_micros": 5500,
"fee_bps": 1000,
"fee_micros": 500,
"net_micros": 5000
}
]
}
gross is the all-in price the agent paid, net what the publisher receives, fee the clearing fee. This is a record only; no money moves.
GET /v1/admin/telemetry
All recorded SPUR events, oldest first. Filter with ?entitlement_id=ent_.... Each record wraps a SPUR standalone event document with provenance:
{
"events": [
{
"received_at": "2026-10-28T10:51:17.501Z",
"emitter": "exchange",
"entitlement_id": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
"document": {
"document_type": "event",
"schema_version": "1.0",
"session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e",
"agent_id": "my-agent",
"started_at": "2026-10-28T10:51:10.370Z",
"event": { "type": "content_retrieved", "...": "see telemetry.md" }
}
}
]
}
emitter is exchange (the content_retrieved events it emits) or agent (reported stages).
GET /v1/admin/rate-cards
Every publisher's current rate card, with the source that supplies it:
{
"rate_cards": [
{
"supply_id": "local-catalogue",
"publisher": { "publisher_id": "pub_harbour_ledger", "name": "The Harbour Ledger", "domain": "harbourledger.example" },
"rate_card": { "schema": "clear-exchange.rate-card", "schema_version": "1.0", "rate_card_id": "rc_harbour_ledger_std", "version": 3, "...": "see rate-cards.md" }
}
]
}
Web UI routes
Static files from a fixed allowlist, served with a strict Content-Security-Policy: / (marketing page), /app (agent console, operator and publisher views), /coverage (the coverage page, rendered at startup from the same data as GET /v1/coverage), and /ui/tokens.css, /ui/base.css, /ui/site.css, /ui/styles.css, /ui/app.js, /ui/lib.js, /ui/favicon.svg, and the headline font under /ui/fonts/ (two WOFF2 files and OFL.txt). GET and HEAD only. See Web UI.
The documentation is served at /docs/ and /docs/<page> (rendered from docs/*.md, with /ui/docs.css, /ui/docs.js and the search index /ui/docs-search.json), and at the root of any hostname in DOCS_HOSTS. See Docs site.
Errors
Errors are JSON with a stable machine-readable code:
{ "error": { "code": "offer_already_accepted", "message": "offer has already been accepted", "details": { "entitlement_id": "ent_..." } } }
details is present only for some codes. Match on code, not message.
| Status | Code | When |
|---|---|---|
| 400 | invalid_request | Body is not valid JSON or fails validation. message names the field. |
| 401 | admin_required | Admin endpoint without the right ADMIN_TOKEN. |
| 401 | entitlement_required | Content or telemetry request without a Bearer token. |
| 401 | entitlement_malformed, entitlement_unsupported_alg, entitlement_unknown_key, entitlement_bad_signature, entitlement_wrong_issuer, entitlement_wrong_audience, entitlement_not_yet_valid, entitlement_expired | The token is not valid. |
| 403 | entitlement_wrong_content | The token is for other content. |
| 403 | entitlement_insufficient_usage | The token grants a narrower tier than required. |
| 403 | offer_forbidden | Accepting an offer made to another agent. |
| 403 | session_forbidden | session_id belongs to another agent. |
| 403 | telemetry_forbidden | Telemetry batch does not match the entitlement. details lists the problems. |
| 404 | not_found | No such route. |
| 404 | offer_not_found | Offer unknown or expired. |
| 404 | content_not_found | The source no longer has the content. |
| 405 | method_not_allowed | Known path, wrong method. |
| 409 | offer_already_accepted | One entitlement per offer. details.entitlement_id is the existing one. |
| 413 | payload_too_large | Body over 1 MB. |
| 422 | telemetry_invalid | Telemetry batch fails validation. details lists the problems. |
| 500 | internal | Unexpected error. |
| 502 | supply_unavailable | The source failed or is not configured. Nothing was charged; retry with the same entitlement. |
| 502 | supply_content_mismatch | The source returned text that does not match the offered hash. Nothing was charged. |
There is no rate limiting yet (see Roadmap).