For developers
Use published business information in your application.
Read a business's own records — prices, services, opening hours, stock — as data, straight from the business that published them. HTTP and JSON, no SDK. This page machine-readable: /docs/openapi.json.
On this page
A successful read
Every published business answers on a public URL. No account, no key, nothing to install — swap in the publisher's slug, which is the last segment of their public page address:
curl -s https://x-protocols.com/b/{publisher} -H 'accept: application/json'
{
"publisher": {
"slug": "{publisher}",
"display_name": "…",
"verified": true,
"website": "https://…",
"website_verified": true,
"url": "https://x-protocols.com/b/{publisher}",
"query_api": "https://x-protocols.com/t/{publisher}/mcp"
},
"records": [
{ "id": "SKU-123", "type": "product",
"title": "…", "url": "https://x-protocols.com/b/{publisher}/product/SKU-123",
"attributes": { "…" }, "updated_at": "2026-09-11T09:12:00Z" }
],
"bulk_export": "https://x-protocols.com/b/{publisher}/dump.jsonl"
}
That is the whole crawl surface: the same URL a person reads as a page.
bulk_export is the cheapest way to ingest a publisher wholesale — one JSON object per
line. query_api is where you ask questions instead of fetching everything, which is
the rest of this page.
Authentication
Most publishers can be read with no credentials at all. Anonymous read is on by default — publication is the point of the platform — so the crawl surface, the discovery documents, the feeds and, for most publishers, the query endpoint all answer an unauthenticated request.
A bearer token does two things: it identifies you for rate limiting, and it is required
for a publisher who has closed anonymous reads. That publisher answers an unauthenticated request
with 401 unauthorized.
Authorization: Bearer kc_live_<prefix>_<secret>
Publishers create and rotate their own keys in Developer access. Keys are
rate-limited per plan; exceeding the limit returns 429 with a Retry-After
header. A revoked key stops working within the tenant cache TTL, not instantly.
Your first query
The query endpoint speaks MCP over Streamable HTTP — JSON-RPC 2.0 over a single POST. Ask what a publisher offers before you ask it anything:
curl -s https://x-protocols.com/t/{publisher}/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Then call one. Every answer comes back in the same envelope whatever the tool was, so you learn the shape once and apply it to every business on the registry:
curl -s https://x-protocols.com/t/{publisher}/mcp \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call",
"params":{"name":"knowledge.search","arguments":{"query":"opening hours on Sunday"}}}'
The envelope carries result, a coverage block, a
freshness contract, expires_at, a revocation_url and an
Ed25519 attestation. The next two sections are the two of those you cannot skip.
Understanding coverage
The one property worth designing around
When a question cannot be answered from what a publisher has actually published,
this API returns coverage.status = not_covered — it does not guess.
A refusal is information: it means the fact was never published, not that it is unknown to the business.
Treat it as a signal, not an error, and do not fall back to inference.
Coverage has four states. Two of them return records.
resolved | The returned records are authoritative and answer the whole request — the topic, every fact asked for, and every constraint named. Quote figures exactly. |
partial | Records are returned and covered is true, but
the request was not fully answered. coverage.unresolved names, in the
imperative, exactly what you may not claim — typically a figure the publisher has not published.
Reading only covered here is how a correct-looking integration invents a price. |
needs_context | Content is withheld pending one missing value;
clarification says which. Returning six possible prices beside "covered: false" is
the original failure with extra steps. |
not_covered | This publisher has no data for that request. Tell the user you could not verify it; do not substitute general knowledge. |
The invariant, if you read one line of this page:
status == "resolved" <=> covered && answers_request && unresolved is empty
coverage.agent_instruction is a sentence written for a model
rather than for you: pass it through to whatever is composing the reply. It changes with the state,
and it is the same sentence on every publisher.
Verification
Every answer carries an Ed25519 signature over the whole envelope, and the address of
the key that signed it. Fetch the publisher's keys from their JWKS document, match the
kid in the envelope, and check the signature — about fifteen lines in any language.
Worked example, with the canonical payload rules and the expiry and revocation checks:
Verify a response.
Retired keys stay published, so an envelope signed before a rotation stays
verifiable. Always match on kid; never assume the first key in the set.
A signature proves the envelope is intact and came from that key. Who that key belongs to
is a separate question, answered by the authority evidence on a publisher's own verification page
at /b/{publisher}/verify — where each proof is printed with what it does and does not
establish. Control of a domain name is one accepted proof; it is not the only one, and it is not
what makes a publisher readable here.
Feeds and live data
Three different things keep a publisher's data current, and they need different amounts of engineering:
| Pull feed | The publisher puts an XML file on their own site and registers the URL. We poll it. No developer required and no credentials of any kind — see the feed format. |
| Push | POST /ingest/{stream}, signed HMAC-SHA256. For a system
that already knows the moment stock changes. |
| Syndication | ACP product feeds and UCP profile/catalogue endpoints, for agentic commerce clients that speak those protocols. |
A publisher who sells nothing answers the ACP feed with
404 not_syndicated. That is the correct outcome, not an error: a dentist has no product
catalogue, and inventing an empty one would be a claim they never made.
Outbound webhooks
Instead of polling for changes, a publisher's own systems can be told the moment their catalogue changes. Subscriptions are created in Developer access — no route here creates one; this section is for whatever receives the POST.
One delivery per write call, not per record: a bulk import of a thousand rows fires
one record.upserted event carrying all of them, not a thousand deliveries.
Event types and payload
record.upserted | Fires once per write call.
{"created": N, "updated": N, "items": [{"type_key","external_id","title"}, ...]} |
record.deleted | {"type_key": "...", "external_id": "..."} |
Headers
X-KC-Timestamp | Unix seconds when this delivery attempt was signed. |
X-KC-Signature | sha256=<hex> — see below. |
X-KC-Event-Type | record.upserted or record.deleted. |
Idempotency-Key | Stable per delivery row. A retry of the same event reuses it — the safe way to de-duplicate if a prior attempt's response was lost after we received it. |
Verifying a delivery
HMAC-SHA256, hex-encoded, prefixed sha256= — the same scheme in both directions, so a
verifier already written for an inbound live-data push can be reused here unchanged:
signature = "sha256=" + hex( HMAC_SHA256(secret, timestamp + "." + raw_request_body) )
Sign the exact bytes of the request body, before any JSON re-serialization — re-encoding first is the most common reason a verifier disagrees with a correct signature. The secret is shown once, at subscription creation, in Developer access.
Retries and auto-disable
A non-2xx response or a connection failure is retried with backoff starting at 1 minute and doubling up to a 15-minute cap, for up to 8 attempts total before that one event is given up on. A subscription whose endpoint exhausts every attempt on 5 consecutive events is automatically disabled — visible and re-enableable in Developer access, rather than silently retrying a dead endpoint forever.
Errors
400 | bad_body, invalid_json — the request
body is not what the endpoint accepts. |
401 | unauthorized — the key is missing or invalid, or this
publisher has closed anonymous reads and you sent no key. |
403 | publisher_suspended — the publisher exists and is not
currently serving. A browser request from an origin the publisher has not allowed is also a 403. |
404 | unknown_publisher — no such publisher, or the
publisher has not published. Both answer identically on purpose: confirming that an
unpublished business exists here would be free reconnaissance. On the ingest route,
unknown_stream means the publisher is known and that stream key is not. |
429 | rate_limited. Honour Retry-After. |
503 | attestation_unavailable — we could not sign, so we do
not answer. The publisher's data is fine; this is our failure, and Retry-After says
when to come back. Never treat it as "this publisher has nothing". |
503 | publisher_unavailable — we could not read the publisher
you asked for. It is not a 404: it says nothing about whether the publisher exists.
On the ingest route it means send the update again; on a key, it means the key was not checked,
not that it is wrong. Honour Retry-After. |
503 | key_set_unavailable — jwks.json could not be
read in full, so none of it is served. A partial key set would make everything a missing key
signed look forged. |
A not_covered answer is a 200, not an error. It is the API
working as designed.
Every endpoint
Query
POST /t/{publisher}/mcp
API key
Ask this publisher a question
MCP over Streamable HTTP, JSON-RPC 2.0. Call tools/list to see what this publisher offers, then tools/call. Every answer carries a coverage status, a freshness contract and an Ed25519 signature. When a question cannot be answered from published records the response says not_covered rather than guessing.
POST /mcp
API key
Same, with the publisher taken from the Host header
Identical to the above. Use this form when a publisher is reached on its own hostname rather than a /t/{publisher} path.
Discovery
GET /t/{publisher}/.well-known/x-protocol/registry.json
public
What this publisher is and can be asked
The discovery document: identity, available tools, protocol version, and links to every other document here.
GET /t/{publisher}/.well-known/x-protocol/manifest.json
public
The AI manifest
What data this publisher offers, in which formats, where the endpoints are, how authenticity is checked and when the data was last updated.
GET /t/{publisher}/.well-known/x-protocol/jwks.json
public
Public signing keys
Every key this publisher has ever signed with, active first. Retired keys stay published so an envelope signed months ago stays verifiable after a rotation. Match on the kid in the envelope, never on position.
GET /t/{publisher}/.well-known/x-protocol/revocations.json
public
Withdrawn records
An empty list is a positive assertion: nothing you hold was withdrawn. For a publisher that does not exist this returns 404, never an empty list, so you can tell the two apart.
Public data
GET /{country}/{city}/business/{publisher}
public
The publisher's public page
Server-rendered HTML, no JavaScript required. Send Accept: application/json to get the same content as JSON. A publisher that has not yet said where it is lives at /b/{publisher} instead, and that flat form keeps resolving afterwards: it answers 301 to this address, so follow redirects.
GET /{country}/{city}/business/{publisher}/llms.txt
public
Model-oriented index
A markdown index written for models: what this publisher publishes, which questions need context, every endpoint, and how to verify any of it.
GET /{country}/{city}/business/{publisher}/dump.jsonl
public
Bulk export
Every published record, one JSON object per line. The cheapest way to ingest a publisher wholesale.
GET /{country}/{city}/business/{publisher}/sitemap.xml
public
Sitemap for this publisher
One URL per published record.
GET /{country}/{city}/business/{publisher}/{type}/{id}
public
One record
A permanent URL per record. Accept: application/json works here too.
GET /{country}/{city}/business/{publisher}/verify
public
What was verified, and when
Human-readable proof behind the trust badge.
GET /llms.txt
public
Platform-wide model index
Every publisher, and how to read them.
GET /sitemap.xml
public
Platform sitemap
Index of every publisher's sitemap.
Live data
POST /ingest/{stream}
HMAC signed
Push live data (stock, prices, free slots)
HMAC-SHA256 over "{timestamp}.{body}" in X-KC-Signature, with X-KC-Timestamp. Identify the publisher with X-KC-Publisher. Idempotent per idempotency key. For publishers without a developer there is a pull alternative: publish an XML feed and register its URL instead — see /docs/live-data-feed.
Syndication
GET /t/{publisher}/syndication/acp/products.jsonl
public
ACP product feed
OpenAI/Stripe Agentic Commerce Protocol shape, one Product per line. ETag and gzip supported. Publishers who sell nothing return 404 not_syndicated, which is correct rather than an error.
GET /t/{publisher}/syndication/acp/header.json
public
ACP feed metadata
Feed-level metadata for the above.
GET /t/{publisher}/.well-known/ucp
public
UCP business profile
Public and unauthenticated by the protocol's own requirement.
GET /t/{publisher}/ucp/v1/products
public
UCP live catalogue search
Never cached; answers from live data where the publisher publishes it.
Operations
GET /healthz
public
Liveness
200 when the process is up.
GET /readyz
public
Readiness
200 when dependencies (database, engine, Redis) are reachable.