Skip to content

Exchange Manifest

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.

PropertyValue
TransportTLS required (HTTPS)
AuthenticationNone (public endpoint)
Cache-Controlmax-age=3600, must-revalidate — keys carry their own not_before/not_after; use the WBA directory’s revocation_url channel for emergency revocation
Content-Typeapplication/json
GenerationStatic JSON from config at startup
{
"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 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"
}
FieldTypeRequiredDescription
verstringYesVersion 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
rolestringYesROLE_EXCHANGE for an Exchange manifest
domainstringYesCanonical domain of this Exchange
namestringNoHuman-readable Exchange name
operatorstringNoOrganization operating this Exchange
operator_domainstringNoOperator’s corporate domain
endpointstringNoExchangeService 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_endpointstringNoHealth check endpoint URL
catalog_endpointstringNoCatalogService 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_supportedstring[]NoSupported FORA protocol versions
pricing_models_supportedstring[]NoSupported pricing models
delivery_methods_supportedstring[]NoSupported delivery methods
hash_methods_supportedstring[]NoAccepted content hash methods
accepted_verifiersstring[]NoTrusted attestation verification vendor domains
supported_auth_methodsstring[]NoAuthorization methods supported (ordered by preference)
oidc_issuerstringNoOIDC Discovery URL when OAuth methods are supported
gnap_grant_endpointstringNoGNAP grant endpoint when GNAP is supported
supported_profilesstring[]NoDomain extension profiles this Exchange conforms to (e.g., ["fora-academic-v1", "fora-news-v1"])
base_currencystringNoISO 4217 currency code for all unit_cost values in offers (e.g., "USD")
account_registrationobjectNoHow 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
contactstringNoContact email for integration inquiries
terms_uristringNoTerms of service URL
terms_digeststringNoDigest 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_uristringNoPrivacy policy URL
extobjectNoExtension fields (forward-compatible)

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.com is not a subdomain of a.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/v1 is 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, so https://exchange.example, https://exchange.example:443 and exchange.example all match one another — writing :443 out 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.

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, $dynamicRef and $recursiveRef begins 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 $ref hops. 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 $ref chain 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 $schema in the document names https://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/false as a schema, but this field is a Struct and 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 items is 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: each anyOf/oneOf/allOf branch costs its own subschema and a $ref costs 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 pattern alphabet, 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 \xHH with 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. pattern states its regex as a value and patternProperties states 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, default and examples hold arbitrary JSON values, so the reference, dialect and pattern rules stop at them — a const carrying a $ref member 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. NaN and Infinity are 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, contentEncoding and contentMediaType are 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.

FORA defines one well-known file. Every participant serves a WellKnownManifest at /.well-known/fora.json; the role field says which participant it is.

RoleServed ByPurpose
ROLE_PUBLISHERProviderWho is authorized to sell my content (signing keys live in my WBA directory)
ROLE_EXCHANGEExchangeMy endpoints, capabilities, and accepted verifiers (offer-signing keys live in my WBA directory)
ROLE_AGENTAgent / BrokerMy 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"].

The manifests form a discovery chain that a Broker follows:

1. Agent requests content from provider.example.com
2. 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 keys
4. (If attestation required) Fetch doubleverify.com/.well-known/http-message-signatures-directory
--> verifier's keys for attestation signature verification

Each 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.

The Broker uses the manifest to filter Exchanges before sending any RPCs. This avoids querying Exchanges that cannot fulfill the agent’s requirements.

QuestionManifest 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.

1. Agent requests URI for domain "provider.example.com"
2. Broker fetches provider's fora.json --> finds authorized Exchange
3. Broker fetches exchange's fora.json (role=ROLE_EXCHANGE) --> capabilities;
keys come from the Exchange's WBA directory
4. 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 populated
6. Subsequent requests skip steps 2-4 (cache hit)

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.