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, or all but telemetry in one call: acquire
- 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/acquire, 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. Sessions, offers, issued entitlements, the ledger and telemetry are kept in VAR_DIR and reloaded at startup, so an offer made before a restart or deploy can still be accepted after it, and an entitlement keeps working (production keeps VAR_DIR on a Fly volume). The public demo runs one instance. See Configuration and deploy.
Timing. POST /v1/query and POST /v1/acquire answer within a routing budget (FANOUT_BUDGET_MS, default 900 ms): a source that cannot answer inside it is dropped for that query rather than waited for. Both responses carry timings and a Server-Timing header (gather, rank, accept, retrieve, total, budget, in ms).
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
Or in one round trip, when the agent is happy to take the exchange's pick:
POST /v1/acquire ──▶ the selected offer, already accepted: entitlement + licensed content (or no good match)
POST /v1/telemetry + Bearer entitlement ──▶ as above
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 (attribution). |
constraints.commercial_use | no | true keeps only content licensed for commercial use. |
constraints.share_alike | no | false excludes share-alike content (for example CC BY-SA). |
constraints.attribution_required | no | false excludes content that must be credited. |
constraints.caching_allowed | no | true keeps only content that may be cached. |
constraints.min_cache_seconds | no | Integer ≥ 0. Keeps only content that may be cached for at least this long. |
constraints.reuse_across_end_users | no | true keeps only content where one retrieval may serve many end users. |
The licence filters narrow results in one direction only: the other value is the same as leaving the filter out. They apply to the terms of the tier sold. See Licence terms.
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",
"key_terms": {
"v": 1,
"commercial_use": true,
"attribution_required": true,
"attribution": "Fieldnote Science",
"share_alike": false,
"caching": { "allowed": true, "max_seconds": 604800 },
"reuse_across_end_users": false,
"display": "snippet",
"max_display_chars": 400,
"ai_training": "prohibited",
"content_licence_id": null,
"indemnity": { "status": "not_defined", "note": "Clear Exchange has not yet published an indemnity or liability position. None is given or implied by this offer." },
"summary": "Commercial use OK; credit Fieldnote Science; snippet up to 400 characters; cache 1 week; one end user per retrieval; no AI training."
},
"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": 900,
"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": 800,
"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,
"timings": {
"budget_ms": 900,
"gather_ms": 3,
"rank_ms": 2,
"total_ms": 5,
"within_budget": true,
"dropped_sources": []
}
}
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 key_terms (commercial use, attribution, share-alike, caching, reuse across end users, display, and a one-line summary; see Licence terms), the rate card 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). |
timings | Milliseconds spent routing and fanning out (gather_ms, near 0 on a cache hit), ranking and pricing (rank_ms) and in total, against the routing budget_ms. dropped_sources lists routed sources that did not answer in time (late, skipped or failed); the query cleared without them. The same figures are in the Server-Timing header. |
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, licence terms) 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",
"key_terms": { "commercial_use": true, "share_alike": false, "...": "...", "summary": "Commercial use OK; credit Fieldnote Science; snippet up to 400 characters; cache 1 week; one end user per retrieval; no AI training." },
"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, including the key licence terms and their one-line summary, 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",
"key_terms": { "commercial_use": true, "share_alike": false, "...": "...", "summary": "Commercial use OK; credit Fieldnote Science; snippet up to 400 characters; cache 1 week; one end user per retrieval; no AI training." }
},
"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" }
]
}
}
POST /v1/acquire
Query, accept and retrieve in one round trip. The exchange clears the query exactly as POST /v1/query does, takes the selected offer (the cheapest inside the relevance band, within max_price, for the requested usage tier), accepts it for you and retrieves the content. Use it when you would take the exchange's pick anyway; use the multi-step flow when you want to see the offers and choose.
The request is the same as POST /v1/query (query, agent_id, max_price, usage (the tier), optional session_id and constraints, for example a higher min_relevance). limit is accepted and ignored. An optional Content-Telemetry-ID header is carried into the content_retrieved event and echoed, as on retrieve.
curl -s localhost:8080/v1/acquire -d '{
"query": "Do heat pumps work in cold climates?",
"agent_id": "my-agent",
"max_price": { "amount": "0.010", "currency": "USD" },
"usage": "display_snippet"
}'
Response 201 (trimmed; offer is a full offer as in the query response, entitlement is the accept response, content the retrieve response):
{
"result": "acquired",
"query_id": "qry_1e045b4c-657b-4d2e-b87c-c4abc9708930",
"session_id": "43d05315-0481-4ec4-bc30-de2f6c12865e",
"session_started_at": "2026-10-28T16:14:56.727Z",
"relevance_model": "bm25-v1",
"max_price": { "amount": "0.01", "amount_micros": 10000, "currency": "USD" },
"usage": "display_snippet",
"recency": null,
"stats": { "...": "as in the query response" },
"routing": { "...": "as in the query response" },
"selection": { "rule": "cheapest_in_band", "...": "as in the query response" },
"timings": {
"budget_ms": 900,
"gather_ms": 13,
"rank_ms": 23,
"accept_ms": 1,
"retrieve_ms": 2,
"total_ms": 39,
"within_budget": true,
"dropped_sources": []
},
"offer": {
"offer_id": "off_cd3b3a9d-3c17-431d-8a9f-1c4acc5e07ff",
"relevance": 0.8044,
"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",
"price": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"in_band": true,
"selected": true,
"...": "every other offer field"
},
"entitlement": {
"entitlement": "eyJhbGciOiJFZERTQSIsInR5cCI6ImN4LWVudGl0...",
"entitlement_id": "ent_5ebcb3d0-3cb6-4726-ae06-418bbd8a6475",
"expires_at": "2026-10-28T16:29:56.000Z",
"content_id": "fn-2026-0153",
"usage": "display_snippet",
"price": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" },
"licence": { "usage": "display_snippet", "terms": { "citation_required": true, "...": "..." }, "rate_card_ref": "rc_fieldnote@v1", "applied_rules": [] },
"session_id": "43d05315-0481-4ec4-bc30-de2f6c12865e",
"retrieve_url": "/v1/content/fn-2026-0153",
"telemetry_url": "/v1/telemetry"
},
"content": {
"content_id": "fn-2026-0153",
"title": "Do heat pumps work in cold climates? What the field data shows",
"text": "Monitoring data from more than 2,000 homes...",
"licence": { "entitlement_id": "ent_5ebcb3d0-3cb6-4726-ae06-418bbd8a6475", "usage": "display_snippet", "display": { "allowed": true, "max_chars": 400 }, "...": "..." },
"clearing": { "ledger_entry_id": "led_...", "gross": "0.0055", "publisher": "0.005", "fee": "0.0005", "fee_bps": 1000, "currency": "USD" },
"telemetry_event_id": "..."
}
}
The entitlement is an ordinary one: verify it, retrieve again with it (not charged twice) and report telemetry against it with session_id and session_started_at from the response.
When nothing qualifies the response is 200 with "result": "no_good_match", the same no_match and price_hint objects as the query response, and no entitlement, content or charge:
{
"result": "no_good_match",
"no_match": { "reason": "over_budget", "message": "Relevant content exists, but none of it within max_price. See price_hint for what would clear.", "floor": 0.5, "best_relevance": 0.8044 },
"price_hint": { "over_budget": 1, "cheapest": { "price": { "amount": "0.0055", "amount_micros": 5500, "currency": "USD" }, "relevance": 0.8044 }, "most_relevant": { "...": "..." } },
"timings": { "budget_ms": 900, "gather_ms": 3, "rank_ms": 2, "total_ms": 5, "within_budget": true, "dropped_sources": [] }
}
Errors are those of query, accept and retrieve. If the source fails at retrieval (502 supply_unavailable or supply_content_mismatch), the offer has already been accepted but nothing is charged, and details carries entitlement, entitlement_id, retrieve_url and offer_id so you can retry GET retrieve_url with that entitlement.
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"}.
Agent authentication
Agent calls (/v1/query, /v1/offers/{id}/accept, /v1/content/{id}, /v1/telemetry, /v1/acquire) accept an optional X-Agent-Key header and optional Web Bot Auth request signatures. With credentials the agent acts under the id the operator issued, has its budget and rate limit enforced and receives entitlements bound to it; without them the self-declared agent_id works as before. See Agent authentication.
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.
/v1/admin/agents
Issue, list, update, rotate and revoke agent API keys: POST /v1/admin/agents (returns the key once), GET /v1/admin/agents, GET, PATCH and DELETE /v1/admin/agents/{id}, and POST /v1/admin/agents/{id}/rotate-key. Ledger entries carry agent_assurance (api_key or web_bot_auth) for authenticated buyers. Request and response fields are in Agent authentication.
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" }
}
]
}
GET /v1/admin/timings
Recent query and acquire timings (the last 500, in memory, reset on restart): percentiles overall and per routed source, and how often the routing budget dropped each source.
{
"window": 500,
"samples": 3,
"budget_ms": 900,
"by_endpoint": { "query": 1, "acquire": 2 },
"cache_hits": 1,
"total_ms": { "count": 3, "p50": 6, "p95": 39, "max": 39 },
"gather_ms": { "count": 2, "p50": 3, "p95": 13, "max": 13 },
"rank_ms": { "count": 3, "p50": 5, "p95": 23, "max": 23 },
"retrieve_ms": { "count": 1, "p50": 2, "p95": 2, "max": 2 },
"gather_over_budget": 0,
"sources": [
{ "source_id": "local-catalogue", "routed": 2, "ok": 2, "late": 0, "skipped": 0, "errors": 0, "dropped_rate": 0,
"latency_ms": { "count": 2, "p50": 1, "p95": 3, "max": 3 } }
],
"recent": [
{ "at": "2026-10-28T16:14:56.855Z", "endpoint": "acquire", "cache": "miss", "budget_ms": 900, "gather_ms": 3, "rank_ms": 3, "total_ms": 6,
"sources": [{ "source_id": "local-catalogue", "status": "ok", "latency_ms": 1, "tmax_ms": 800 }] }
]
}
gather_ms and the per-source figures count cache misses only (a cache hit asks no source). dropped_rate is the share of routed queries the source missed.
GET /v1/admin/entitlements
Entitlements the exchange has issued that are still inside their telemetry window, with what is in the state store. Filter with ?agent_id=. state.durable is true when the state is kept in VAR_DIR (it survives restarts).
{
"state": { "sessions": 2, "offers": 3, "entitlements": 1, "durable": true },
"entitlements": [
{
"entitlement_id": "ent_5ebcb3d0-3cb6-4726-ae06-418bbd8a6475",
"offer_id": "off_cd3b3a9d-3c17-431d-8a9f-1c4acc5e07ff",
"agent_id": "my-agent",
"session_id": "43d05315-0481-4ec4-bc30-de2f6c12865e",
"content_id": "fn-2026-0153",
"publisher_id": "pub_fieldnote",
"supply_id": "local-catalogue",
"usage": "display_snippet",
"price_micros": 5500,
"currency": "USD",
"issued_at": "2026-10-28T16:14:56.000Z",
"expires_at": "2026-10-28T16:29:56.000Z"
}
]
}
The token itself is not stored; it is signed and self-contained.
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 (from POST /v1/acquire, it is in details.entitlement). |
| 502 | supply_content_mismatch | The source returned text that does not match the offered hash. Nothing was charged. |
Agent authentication adds the codes in Agent authentication: agent_auth_required, agent_key_invalid, agent_signature_*, agent_id_mismatch, entitlement_agent_mismatch, agent_budget_exceeded (402) and rate_limit_exceeded (429, with Retry-After). Rate limits apply per agent key; anonymous demo agents are not limited.