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:

bash read one publisher
curl -s https://x-protocols.com/b/{publisher} -H 'accept: application/json'
json response (abridged)
{
  "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.

http request header
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:

bash list what a publisher offers
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:

bash ask a question
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.

resolvedThe returned records are authoritative and answer the whole request — the topic, every fact asked for, and every constraint named. Quote figures exactly.
partialRecords 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_contextContent is withheld pending one missing value; clarification says which. Returning six possible prices beside "covered: false" is the original failure with extra steps.
not_coveredThis 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:

text what resolved means
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 feedThe 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.
PushPOST /ingest/{stream}, signed HMAC-SHA256. For a system that already knows the moment stock changes.
SyndicationACP 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.upsertedFires once per write call. {"created": N, "updated": N, "items": [{"type_key","external_id","title"}, ...]}
record.deleted{"type_key": "...", "external_id": "..."}

Headers

X-KC-TimestampUnix seconds when this delivery attempt was signed.
X-KC-Signaturesha256=<hex> — see below.
X-KC-Event-Typerecord.upserted or record.deleted.
Idempotency-KeyStable 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:

text what X-KC-Signature is
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

400bad_body, invalid_json — the request body is not what the endpoint accepts.
401unauthorized — the key is missing or invalid, or this publisher has closed anonymous reads and you sent no key.
403publisher_suspended — the publisher exists and is not currently serving. A browser request from an origin the publisher has not allowed is also a 403.
404unknown_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.
429rate_limited. Honour Retry-After.
503attestation_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".
503publisher_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.
503key_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.