Exchange Manifest
Overview
Section titled “Overview”An Exchange describes itself with a WellKnownManifest carrying role=ROLE_EXCHANGE. Analogous to OIDC Discovery (/.well-known/openid-configuration), it enables automated discovery and capability negotiation by Brokers and agents.
The manifest answers: “What can this Exchange do, where are its endpoints, and what capabilities does it support?” The keys that sign its offers live in the Exchange’s WBA directory (the JWK Set at /.well-known/http-message-signatures-directory), introduced below.
Endpoint: https://{domain}/.well-known/fora.json — the single canonical file every FORA participant serves, differentiated by role.
| Property | Value |
|---|---|
| Transport | TLS required (HTTPS) |
| Authentication | None (public endpoint) |
| Cache-Control | max-age=3600, must-revalidate — keys carry their own not_before/not_after; use the WBA directory’s revocation_url channel for emergency revocation |
| Content-Type | application/json |
| Generation | Static JSON from config at startup |
Full JSON Example
Section titled “Full JSON Example”{ "ver": "1.0", "role": "ROLE_EXCHANGE", "domain": "exchange.example.com", "name": "Example Content Exchange", "operator": "Example Media Corp", "operator_domain": "examplemedia.com", "endpoint": "https://exchange.example.com/exchange/v1", "health_endpoint": "https://exchange.example.com/healthz", "catalog_endpoint": "https://exchange.example.com/catalog/v1", "protocol_versions_supported": ["1.0"], "pricing_models_supported": [ "free", "per_unit", "flat" ], "delivery_methods_supported": [ "direct", "instructions", "streaming" ], "supported_profiles": [ "fora-academic-v1", "fora-news-v1" ], "base_currency": "USD", "hash_methods_supported": [ "sha-256", "sha-384" ], "accepted_verifiers": [ "doubleverify.com", "integralads.com" ], "supported_auth_methods": ["gnap", "oauth_dpop"], "oidc_issuer": "https://exchange.example.com", "account_registration": { "data_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "required": ["legal_name", "vat_id"], "properties": { "legal_name": { "type": "string", "minLength": 2 }, "vat_id": { "type": "string", "pattern": "^[A-Z]{2}[0-9]+$" } } } }, "contact": "fora-integration@examplemedia.com", "terms_uri": "https://examplemedia.com/fora-terms", "terms_digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "privacy_uri": "https://examplemedia.com/privacy"}The WBA Directory
Section titled “The WBA Directory”The Exchange’s offer-signing keys are not carried in fora.json. They live in a separate, pure WBA directory — a JWK Set served at /.well-known/http-message-signatures-directory with Content-Type: application/jwk-set+json. Keys are identified by their RFC 7638 thumbprint (the RFC 9421 keyid), not by a kid label, and the optional emergency revocation list now lives here as revocation_url:
// GET /.well-known/http-message-signatures-directory// Content-Type: application/jwk-set+json{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "not_before": "2026-04-01T00:00:00Z", "not_after": "2026-10-01T00:00:00Z" } ], "revocation_url": "https://exchange.example.com/.well-known/fora-invalidations.json"}Field Reference
Section titled “Field Reference”| Field | Type | Required | Description |
|---|---|---|---|
ver | string | Yes | Version of this manifest document’s layout — "1.0", stamped from the SDK’s WellKnownManifestVersion, a namespace separate from the RPC envelope ver and never derived from it. A manifest layout change bumps both numbers; a protocol change that leaves the manifest untouched bumps only the envelope’s. Consumers read it before any other member: a recognised major is accepted whatever the minor, an unrecognised major, a malformed value, or an absent ver is refused |
role | string | Yes | ROLE_EXCHANGE for an Exchange manifest |
domain | string | Yes | Canonical domain of this Exchange |
name | string | No | Human-readable Exchange name |
operator | string | No | Organization operating this Exchange |
operator_domain | string | No | Operator’s corporate domain |
endpoint | string | No | ExchangeService endpoint URL. Must be on the host and port that serve this manifest, or a subdomain of that host on that port, and must not carry userinfo — see Endpoint host binding |
| (offer-signing keys) | — | — | Not in fora.json. See the WBA directory at /.well-known/http-message-signatures-directory |
health_endpoint | string | No | Health check endpoint URL |
catalog_endpoint | string | No | CatalogService endpoint URL, if the Exchange exposes the service. Same binding as endpoint — on the host and port that serve this manifest, or a subdomain of that host on that port, no userinfo — see Endpoint host binding. Unlike endpoint, no SDK reads or checks this field: the catalog address is configuration, so a deployment that does read it from a manifest must apply the binding itself before dialling. Absent means not exposed; a publisher’s client does not fall back to endpoint |
protocol_versions_supported | string[] | No | Supported FORA protocol versions |
pricing_models_supported | string[] | No | Supported pricing models |
delivery_methods_supported | string[] | No | Supported delivery methods |
hash_methods_supported | string[] | No | Accepted content hash methods |
accepted_verifiers | string[] | No | Trusted attestation verification vendor domains |
supported_auth_methods | string[] | No | Authorization methods supported (ordered by preference) |
oidc_issuer | string | No | OIDC Discovery URL when OAuth methods are supported |
gnap_grant_endpoint | string | No | GNAP grant endpoint when GNAP is supported |
supported_profiles | string[] | No | Domain extension profiles this Exchange conforms to (e.g., ["fora-academic-v1", "fora-news-v1"]) |
base_currency | string | No | ISO 4217 currency code for all unit_cost values in offers (e.g., "USD") |
account_registration | object | No | How to open an account here. Its data_schema is a JSON Schema (draft 2020-12) for the registration_data an agent sends to Register. Present means this Exchange enforces it on account creation and accepts registration through the API; absent means registration_data passes through uninspected. A repeat registration runs no schema check either way — it is answered from the stored record. It must satisfy every rule in Registration schema rules below — self-contained and acyclic, 16KB, 32 containers deep, 10,000 evaluations of work, and a restricted pattern alphabet |
contact | string | No | Contact email for integration inquiries |
terms_uri | string | No | Terms of service URL |
terms_digest | string | No | Digest of the document at terms_uri, in method:hexdigest form, pinning which terms document you are currently offering. A registration echoes it, the request signature covers that echo, and you record the accepted value with the account — which is what answers “which terms did this operator accept” after a later revision. Only allowed alongside terms_uri. Publishing it for the first time refuses every client that does not yet echo it when that client is registering for the first time, so treat it as a coordinated change. An agent that already holds an account is unaffected: a repeat registration is answered from the stored record and runs no terms gate, which is what keeps existing accounts reachable across a terms revision |
privacy_uri | string | No | Privacy policy URL |
ext | object | No | Extension fields (forward-compatible) |
Endpoint host binding
Section titled “Endpoint host binding”endpoint MUST be on the same host and port that serve this manifest, or
on a subdomain of that host on that port, and MUST NOT carry userinfo. An
Exchange at exchange.example may advertise https://exchange.example/v1 or
https://api.exchange.example/v1; it may not advertise
https://cdn.other.example/v1, nor https://exchange.example:8443/v1 unless its
fora.json is served from :8443 too.
The anchor is the host the consumer fetched this document from — not the
domain field inside it. That distinction is the whole check: domain is
self-asserted, so a hostile manifest that anchored to it could simply set domain
to match whatever endpoint it wanted and validate itself. A conformant Exchange
has the two agree, which is why the difference only shows against a manifest worth
refusing.
The reason is the trust chain. This document is fetched from the domain an offer named, and it is only as trustworthy as the host that served it. If it could name an endpoint on an unrelated host, whoever answers for the manifest could redirect a signed call to a party the offer’s signature never covered — and a dial-time address guard would not object, because the destination is a perfectly ordinary public host.
Two details matter in practice:
- The host match is on a full dot-delimited label boundary.
evil-a.comis not a subdomain ofa.com; a bare suffix comparison gets that wrong, and it is the mistake an attacker registers a domain to exploit. - The port is compared too. Another port is another service, which the party
publishing the manifest need not control, so
https://exchange.example:8443/v1is a valid endpoint only where the port is named on both sides. A port equal to the scheme’s default and an omitted port are the same port, sohttps://exchange.example,https://exchange.example:443andexchange.exampleall match one another — writing:443out is not a refusal. - The scheme is not compared here. Whether a leg may run in the clear is the transport’s decision, and the default-port rule above is scheme-relative so that it cannot become a scheme check by accident.
An Exchange that serves its API from a separate domain should front it under its own subdomain rather than advertising the other domain directly.
Registration schema rules
Section titled “Registration schema rules”account_registration.data_schema is the one part of this manifest a client both
reads out of a third party’s document and then executes, before any
signature has been checked. A JSON Schema is a small program, so an unbounded one
is a denial-of-service vector and one that resolves references is an SSRF vector
aimed wherever its author points. A publisher MUST satisfy every rule below,
and a consumer MUST refuse a schema that does not.
The SDK is that consumer for a registering agent: it reads this block from a
freshly fetched manifest — never a cached one, because the terms digest beside
it cannot be checked for staleness locally — over its SSRF-guarded transport, since
the Exchange domain arrives on the request rather than from configuration. The
document’s own ver is read first, on the same terms as everywhere else: a layout
the reader cannot classify supplies neither the schema below nor the terms digest a
registration would echo. It then compiles the schema under every rule here, and
pre-checks the payload before anything is signed. A schema it refuses is
reported as a verdict and the registration is sent anyway: refusing locally would
turn a rule about reading a third party’s document into a denial of service against
the agent’s own user, so the Exchange’s own enforcement stays the deciding check.
The 16KB cap is measured over the bytes as served, in every language, so a
schema padded past it on the wire is refused at both ends rather than at one.
A payload the pre-check does refuse comes back naming the offending members, as the
same typed RegistrationFailure detail an Exchange attaches when it refuses the same
payload — so an agent reads one shape whichever side declined, and its tooling needs
one renderer rather than two. The detail’s domain names the client’s own tier rather
than the Exchange, which never saw the request. Each entry addresses a member by RFC
6901 pointer, with the empty pointer addressing registration_data itself; the
constraint text beside it is developer-facing and validator-defined, so branch on the
pointer and the typed reason, never on that prose.
- Self-contained. Every
$ref,$dynamicRefand$recursiveRefbegins with#. A consumer never resolves a reference that leaves the document — doing so turns every reader into a fetch aimed at a URL the schema’s author chose. - 100 reference hops, counted as the longest path of
$refhops. A third axis, because a flat chain of definitions is three containers deep however long it is and costs one evaluation per link — so neither the depth cap nor the work cap sees it, while the recursion a validator performs to resolve it grows with every link. - No reference cycles. A
$refchain never returns to a schema already on it. The construct is legal JSON Schema and is how a recursive structure is written, but its cost has no static bound and it is what makes a validator recurse until it aborts. Registration data describes a business entity, which is not recursive. - One dialect. Every
$schemain the document nameshttps://json-schema.org/draft/2020-12/schema(a trailing#is the same value). A document declaring none is read as 2020-12. - A JSON object. 2020-12 also admits a bare
true/falseas a schema, but this field is aStructand cannot carry one. - 16KB, measured as the UTF-8 bytes of the member as served in this file.
- 32 nested JSON containers, counting the schema object itself as the first.
- 10,000 evaluations, counted statically over the schema — the cost of applying
it at one location in a payload, so a subschema under
itemsis counted once here and evaluated once per element at runtime. This is the bound that matters and the one people expect the other two to provide: eachanyOf/oneOf/allOfbranch costs its own subschema and a$refcosts its target, so branches multiply along a reference chain. A 1,675-byte schema five containers deep — a tenth of the size cap — measured 16.7 million evaluations and twenty-seven seconds against a two-member payload. A definition nobody references costs nothing, so a document may carry a library of them. - A restricted
patternalphabet, stated as what a pattern may contain. A group opens with(or(?:and nothing else; the only escapes are\$,\(,\),\*,\+,\.,\/,\?,\D,\W,\[,\\,\],\^,\d,\f,\n,\r,\t,\v,\w,\{,\|,\}and\xHHwith exactly two hex digits; a counted repeat does not exceed 1000 and states its first bound (a{2,}yes,a{,5}no); a}outside a bracket expression closes a counted repeat, so a literal brace is written\};[:does not appear inside a bracket expression; a bracket expression closes and does not open with]; and a range endpoint is not a shorthand class ([\w-x]). - Patterns live in two places.
patternstates its regex as a value andpatternPropertiesstates its regexes as keys. Both are patterns and both are held to the alphabet; they are the only two keywords in the dialect that carry one. - Data is not schema.
const,enum,defaultandexampleshold arbitrary JSON values, so the reference, dialect and pattern rules stop at them — aconstcarrying a$refmember is a value a payload may equal. Their nesting still counts against the depth cap. - One encoding. The bytes are well-formed UTF-8 and do not begin with a byte
order mark, and a consumer neither strips a mark nor repairs ill-formed bytes.
NaNandInfinityare not JSON and are refused with them. - Absent means empty, or only JSON whitespace — space, tab, carriage return and line feed, and no others. This is the enforcement switch, so bytes read as absent are bytes that turn validation off: a consumer that asks its own language what “blank” means gets three different answers, and a decoder that strips a byte order mark makes a mark followed by a space look like nothing at all. A document that is not empty and not JSON is malformed, which is a refusal, never silence.
- No nested quantifiers. A quantified group’s body cannot itself repeat or
branch:
(a+)+,(a|a)*and([a-z]+)*are refused,(?:ab)+is fine. - Annotations stay annotations.
format,contentEncodingandcontentMediaTypeare never asserted.
The bounds are numbers in the contract rather than each implementation’s choice because a schema is validated at both ends of one registration — the Exchange enforcing it and the client pre-checking against it — so a limit one side invents refuses payloads the other accepts, which is the disagreement the field exists to prevent.
The pattern rules are the ones worth explaining, and there are two of them because
pattern fails in two different ways.
The first is about agreement. Draft 2020-12 patterns are ECMA-262, and the regex
engines real implementations run do not agree on that grammar. Part of the gap is
loud — Go’s RE2 refuses the lookaround and backreferences ECMA allows, so a schema
using them compiles for one implementation and fails for the next. The dangerous part
is silent: \s is [\t\n\f\r ] to RE2, adds the vertical tab in Python, and adds
that plus every Unicode space separator in JavaScript. [:alpha:] inside a bracket
expression is a character class to RE2 and the literal characters :alph to
JavaScript. Every engine compiles those. They then disagree about which registrations
are valid, with nothing logged.
Where a construct can be reconciled it is not refused — $, \d and \w appear
in almost every real pattern, and an implementation whose engine reads them
differently is expected to correct it (match ASCII classes; anchor $ at the end of
the text and nowhere else). Only what cannot be reconciled is excluded — \s, and
\B, which finds no word boundary in the empty string for RE2 and JavaScript and
finds one in Python.
That correction has to reach every place the implementation compiles a regex, not
just the pattern keyword: patternProperties states its regexes as keys, and the
matched-key scans behind additionalProperties and unevaluatedProperties compile
them again. Correcting one and not the others is how a property name that is a
non-ASCII digit came to match ^\d+$ for one implementation and not the other two.
The alphabet is stated as an allowlist for a structural reason: the set of escapes three engines disagree about is open-ended, so a list of forbidden ones needs a new entry every time somebody finds another, and until then the gap is open. The portable set is small and closed, and an author who wants something outside it writes the characters out.
The second rule is about availability, and it is separate because excluding
lookaround and backreferences does not cover it. (a+)+ against a 30-character input
takes minutes: catastrophic backtracking needs neither construct. It has to be a
publishing rule rather than a runtime timeout, because a regex spin holds its
interpreter — a consumer cannot reliably interrupt one it has already started.
A client that finds a schema breaking any of these rejects it and skips its local pre-check rather than repairing or truncating it, sending the payload and letting the Exchange decide — exactly as it would where no schema is published. A local check that cannot run must not become a local veto. An Exchange that cannot compile its own configured schema is looking at a misconfiguration of its deployment, and must not advertise a schema it is not itself enforcing.
The Unified FORA Well-Known File
Section titled “The Unified FORA Well-Known File”FORA defines one well-known file. Every participant serves a WellKnownManifest at /.well-known/fora.json; the role field says which participant it is.
| Role | Served By | Purpose |
|---|---|---|
ROLE_PUBLISHER | Provider | Who is authorized to sell my content (signing keys live in my WBA directory) |
ROLE_EXCHANGE | Exchange | My endpoints, capabilities, and accepted verifiers (offer-signing keys live in my WBA directory) |
ROLE_AGENT | Agent / Broker | My role marker (my Ed25519 request-signing key lives in my WBA directory) |
A verification vendor has no dedicated role — it publishes under its operator’s role (ROLE_PUBLISHER when self-attesting, ROLE_EXCHANGE for a standalone vendor); its keys go in its WBA directory and its claims schema in the manifest’s ext["fora.attestation.claims_schema"].
Discovery Chain
Section titled “Discovery Chain”The manifests form a discovery chain that a Broker follows:
1. Agent requests content from provider.example.com2. Fetch provider.example.com/.well-known/fora.json --> role=ROLE_PUBLISHER; discovers authorized Exchange (keys via the publisher's WBA directory)3. Fetch exchange.example.com/.well-known/fora.json --> role=ROLE_EXCHANGE; discovers endpoints and capabilities, then fetch exchange.example.com/.well-known/http-message-signatures-directory for the offer-signing keys4. (If attestation required) Fetch doubleverify.com/.well-known/http-message-signatures-directory --> verifier's keys for attestation signature verificationEach step narrows the information needed: a publisher manifest tells you WHERE to go, and an exchange manifest tells you WHAT the Exchange can do; the signing keys are resolved from each party’s WBA directory.
Broker Capability Negotiation
Section titled “Broker Capability Negotiation”The Broker uses the manifest to filter Exchanges before sending any RPCs. This avoids querying Exchanges that cannot fulfill the agent’s requirements.
| Question | Manifest Field |
|---|---|
| Does this Exchange support per-unit (metered) pricing? | pricing_models_supported |
| Can this Exchange deliver via signed URL? | delivery_methods_supported |
| Does this Exchange trust DoubleVerify? | accepted_verifiers |
| Does this Exchange speak protocol v1.0? | protocol_versions_supported |
| Where do I verify offer signatures? | the Exchange’s WBA directory (/.well-known/http-message-signatures-directory) |
Without the manifest, the Broker discovers these answers only after sending requests and observing responses — wasting latency and bandwidth on Exchanges that will reject the request.
Bootstrap Flow
Section titled “Bootstrap Flow”1. Agent requests URI for domain "provider.example.com"2. Broker fetches provider's fora.json --> finds authorized Exchange3. Broker fetches exchange's fora.json (role=ROLE_EXCHANGE) --> capabilities; keys come from the Exchange's WBA directory4. Broker caches manifest with 1-hour TTL (emergency revocation via the WBA directory's revocation_url)5. Broker registers Exchange in its registry with capabilities populated6. Subsequent requests skip steps 2-4 (cache hit)Proto Message
Section titled “Proto Message”The Exchange manifest is a WellKnownManifest with role=ROLE_EXCHANGE, defined in fora.proto. Offer-signing keys are not carried here; they live in the Exchange’s WBA directory (the WBAFile JWK Set at /.well-known/http-message-signatures-directory). Exchange-relevant fields:
message WellKnownManifest { string ver = 1; // "1.0" — document schema, not the RPC envelope Role role = 2; // ROLE_EXCHANGE here string domain = 3; optional string contact = 4; optional string name = 9; optional string operator = 10; optional string operator_domain = 11; optional string endpoint = 12; // ExchangeService URL (serving host+port, or a subdomain) optional string health_endpoint = 13; optional string catalog_endpoint = 14; repeated string protocol_versions_supported = 16; repeated PricingModel pricing_models_supported = 17; repeated DeliveryMethod delivery_methods_supported = 18; repeated string hash_methods_supported = 19; repeated string accepted_verifiers = 20; repeated string supported_profiles = 23; repeated AuthMethod supported_auth_methods = 24; optional string oidc_issuer = 25; optional string gnap_grant_endpoint = 26; optional string base_currency = 27; optional AccountRegistration account_registration = 30; // how to open an account here optional string terms_digest = 31; // digest of the document at terms_uri google.protobuf.Struct ext = 15; // ... publisher-only fields (exchanges, catalog_contributors) omitted ...}The JSON endpoint serves the same data as this proto message, but as plain JSON (not protobuf wire format). It is a discovery document, not an RPC response.
Next Steps
Section titled “Next Steps”- Discovery Paths — all the ways an agent enters the FORA flow
- Authentication — Ed25519 key announcement endpoints
- Exchange Overview — the service that serves this manifest
- Broker Overview — how the Broker bootstraps from the manifest