Skip to content

Transaction Flow

The Exchange exposes four RPCs. This is the core protocol — everything else is built on top of it. Both agents and Brokers are clients of the same Exchange interface, with the same RPCs and the same wire. An Exchange tells a relayed request from a direct one by comparing requester.domain with the signer’s directory, and acts on a relayed one only from a Broker it admits; see Direct and relayed requests.

With Broker (multi-exchange, budget control, reporting):
Agent -> Broker -> Exchange A, B, C -> select best
Agent -> Exchange A, B, C directly -> buy, deliver
or Agent -> Broker -> Exchange A, B, C -> buy in one call, deliver
Without Broker (direct, single exchange):
Agent -> Exchange -> deliver

After discovery the agent buys its selected offers one of two ways: directly, calling ExchangeService.ExecuteTransaction at each offer’s Offer.exchange, or through the Broker, with one BrokerService.ExecuteTransaction call for offers from any number of Exchanges. See Buying through a Broker.

Nothing about a request’s route is carried in the message body: the party that sends a request signs it with one labeled RFC 9421 signature in the HTTP headers, a conformant Web Bot Auth signature with its own Signature-Agent member naming its own key directory. FORA’s Broker never forwards an agent’s request unchanged, so it never covers an agent’s signature. Discovery through the Broker is Broker-led: the agent does not know the Exchange at discovery time, so the Broker chooses the Exchanges and originates its own DiscoverResources queries, signed by the Broker alone. Execute through the Broker is re-packaged: the Broker builds a fresh TransactionRequest per Exchange and signs it alone. In both cases the Broker’s signature covers its own request and only its own Signature-Agent member, and the Exchange authenticates the caller as the Broker. On a relayed purchase, the agent’s identity and consent come from the AgentAcceptance, a detached Ed25519 signature over {offer_sig, requester_id, requester_domain, idempotency_key} verified against the agent’s registered key; that key also gives the delivery-URL binding. The rules, including the general WG-00 rule for a party that forwards a request unchanged, are specified once, in Multi-hop forwarding.

Signature cap. WellKnownManifest.max_intermediary_hops is the most signatures an Exchange accepts on a request. Every signature counts, whether or not it covers another, and a request signed only by the party that sent it carries one. The Exchange counts them before it verifies any, and refuses a request carrying more with the Connect code resource_exhausted (HTTP 429) and no typed reason. RequestConstraints.max_hops rides only on the agent’s DiscoveryRequest, which ends at the Broker, so no party receives or enforces it in this version; it is kept for multi-hop Broker discovery.

Response path. Each request is answered to the party that sent it. An Exchange answers a Broker’s query to the Broker, which combines the answers into its DiscoveryResponse to the agent, and a purchase through a Broker is answered by the Broker, which combines the Exchanges’ answers into one BrokerTransactionResponse. Delivery needs no relay: the signed retrieval_endpoint is bound to the agent’s identity (agent_identity_hash), so the agent fetches it directly.

The agent or Broker queries the Exchange to discover available content and pricing.

Discovery has no side effects: it creates no transaction, no billing authorization and no reporting obligation. An agent commits to an offer only on the execute path, with ExecuteTransaction. Discovery is therefore safe to retry. A transport failure, such as an unreachable Exchange or a timeout, is the client’s concern, and the protocol defines no transient-failure code for it.

POST /fora.v1.ExchangeService/DiscoverResources
Content-Type: application/json
{
"ver": "1.0",
"exchange": "exchange.ssp-example.com",
"requester": {
"id": "research-bot",
"domain": "buyer.example.com",
"type": "REQUESTER_TYPE_AGENT",
"scopes": ["*"]
},
"uris": ["https://cdn.fora-protocol.org/premium/ai-infrastructure.html"],
"acceptable_restrictions": [{ "axis": "RESTRICTION_KIND_FUNCTION", "values": ["ai-input"] }]
}

Key fields:

  • requester.domain — required; the host of the agent’s key directory. On this direct request the Exchange fetches the Ed25519 public key from the signer’s key directory at {origin}/.well-known/http-message-signatures-directory (the origin named by the Signature-Agent member the signature covers), verifies the request’s RFC 9421 HTTP Message Signature, and then requires requester.domain to name that same directory: a mismatch is refused as unauthenticated with request_auth_failure SIGNATURE_INVALID (see Requester domain and the key directory)
  • requester.id — required; a free label the agent chooses for attribution, such as which sub-agent or end customer is asking. It is never identity and never used to find keys
  • ResourceQuery.uris — the content URI(s) being requested
  • acceptable_restrictions — the restrictions the agent is willing to accept, grouped by axis; axis FUNCTION carries the intended use (RAG, training, search, etc.)
  • requester.scopes — entitlement scopes; ["*"] means unrestricted public access
  • billing carries no request field: the Exchange resolves the agent’s account (the billing_ref minted at ExchangeService.Register) from what authenticates the agent, never from anything the caller merely states; see Accounts
  • request authentication — an RFC 9421 HTTP Message Signature over the request that follows the Web Bot Auth profile (the agent’s key is published in its key directory); a query that reaches an Exchange through a Broker is one the Broker originated and signed alone (no in-message hop list)
{
"ver": "1.0",
"exchange": "exchange.ssp-example.com",
"offers": [
{
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"exchange": "exchange.ssp-example.com",
"title": "The Future of AI Infrastructure",
"ext": {
"comp.package_id": "PKG-TC-AI-INFRA",
"comp.seller": "cdn.fora-protocol.org",
"comp.citation_required": true,
"comp.content_types": ["CONTENT_TYPE_TEXT"],
"comp.retrieval_type": ["RETRIEVAL_TYPE_HTML"]
},
"pricing": {
"model": "PRICING_MODEL_PER_UNIT",
"rate": "0.00002",
"currency": "USD",
"unit_cost": "0.00002",
"estimated_quantity": 2500,
"unit": "tokens"
},
"delivery_method": "DELIVERY_METHOD_INSTRUCTIONS",
"reporting": {
"required": true,
"window": "86400s",
"required_fields": ["transaction_id", "function", "consumed_quantity"]
},
"identity": {
"canonical_url": "https://cdn.fora-protocol.org/premium/ai-infrastructure.html",
"iptc_guid": "urn:newsml:ap.org:20260310:ai-infra-001"
},
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED",
"restrictions": [
{
"kind": "RESTRICTION_KIND_FUNCTION",
"permitted": ["ai-input", "ai-index", "search"],
"prohibited": ["ai-train"]
},
{
"kind": "RESTRICTION_KIND_USER_TYPE",
"permitted": ["commercial", "education"]
}
]
}
],
"previews": [
{
"url": "https://cdn.fora-protocol.org/preview/ai-infrastructure-snippet.txt",
"media_type": "text/plain",
"size": "sample"
}
],
"signature": "a1b2c3d4e5f6...",
"signature_algorithm": "EdDSA"
}
]
}

Each Offer includes:

  • Offer.title (human-readable) plus optional fora-comp-v1 comp.* ext fields carrying CoMP package metadata (comp.package_id, comp.seller, comp.citation_required, comp.content_types, comp.retrieval_type)
  • FORA Pricing with the rate, currency, and normalized unit_cost; a metered (PER_UNIT) offer may also carry a positive estimated_quantity in its unit. The purchase charges the estimate × rate, or one unit’s rate without an estimate (see Charging a metered purchase)
  • ResourceIdentity for cross-exchange deduplication
  • LicenseTerm entries on terms[], each carrying restrictions (repeated Restriction) derived from the provider’s RSL terms
  • ReportingObligation defining post-usage reporting requirements
  • Preview URLs for pre-transaction evaluation (see Resource Previews)
  • An Ed25519 exchange_signature that proves the Exchange issued this offer

When requesting multiple URIs, the response uses offer_groups instead of offers:

{
"ver": "1.0",
"exchange": "exchange.ssp-example.com",
"offer_groups": [
{
"uri": "https://cdn.fora-protocol.org/premium/ai-infrastructure.html",
"offers": [
{
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.00002", "currency": "USD", "unit_cost": "0.00002", "estimated_quantity": 2500, "unit": "tokens" },
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "a1b2c3...",
"signature_algorithm": "EdDSA"
}
]
},
{
"uri": "https://cdn.fora-protocol.org/premium/gpu-shortage-2026.html",
"offers": [
{
"offer_id": "89636cda-93f6-460e-af7f-2b6aef0f491b",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.00002", "currency": "USD", "unit_cost": "0.00002", "estimated_quantity": 2500, "unit": "tokens" },
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "d4e5f6...",
"signature_algorithm": "EdDSA"
}
]
},
{
"uri": "https://cdn.fora-protocol.org/premium/openai-funding.html",
"offers": []
}
]
}

An empty offers array indicates the content is not available through this Exchange.

When the requester has a subscription, the Exchange includes both per-request and subscription offers:

{
"offers": [
{
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.00002", "currency": "USD", "unit_cost": "0.00002", "estimated_quantity": 2500, "unit": "tokens" },
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "a1b2c3...",
"signature_algorithm": "EdDSA"
},
{
"offer_id": "c5b01a4e-6536-4a42-a4fc-e41a1df1913d",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD", "unit_cost": "0" },
"subscription_id": "SUB-12345",
"terms": [ { "semantics": "TERM_SEMANTICS_ENUMERATED", "scopes": ["subscription:SUB-12345"] } ],
"reporting": { "required": true, "window": "86400s", "required_fields": ["transaction_id", "function", "consumed_quantity"] },
"signature": "g7h8i9...",
"signature_algorithm": "EdDSA"
}
]
}

When the requester holds a subscription, the Exchange attaches SubscriptionQuotaInfo entries so the agent can see remaining allowance before committing. After the transaction, the updated quota appears on the response so the agent can track burn-down without a separate API call.

Where it appears:

Message fields:

FieldTypeDescription
subscription_idstringSubscription this quota applies to
quota_limitint32Total allowed in the current period
quota_usedint32Used so far in the current period
quota_remainingint32Remaining in the current period
resets_atTimestampWhen the quota counter resets (UTC)
unitstringWhat is metered: "accesses", "tokens", "spend_cents", "burst"

Multi-dimensional quotas. A subscription may impose several independent limits — for example, an access count and a spend cap. The subscription_quota field is repeated, so the Exchange returns one entry per dimension:

{
"offers": [
{
"offer_id": "c5b01a4e-6536-4a42-a4fc-e41a1df1913d",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_FREE", "rate": "0", "currency": "USD" },
"subscription_id": "SUB-12345",
"terms": [ { "semantics": "TERM_SEMANTICS_ENUMERATED", "scopes": ["subscription:SUB-12345"] } ],
"subscription_quota": [
{
"subscription_id": "SUB-12345",
"quota_limit": 5000,
"quota_used": 1247,
"quota_remaining": 3753,
"resets_at": "2026-04-01T00:00:00Z",
"unit": "accesses"
},
{
"subscription_id": "SUB-12345",
"quota_limit": 50000,
"quota_used": 12300,
"quota_remaining": 37700,
"resets_at": "2026-04-01T00:00:00Z",
"unit": "spend_cents"
}
],
"signature": "g7h8i9...",
"signature_algorithm": "EdDSA"
}
]
}

Quota decrement timing. The quota counter increments at ExecuteTransaction (optimistic — the Exchange assumes the content will be consumed). If the agent later files a successful DisputeTransaction, the Exchange may reverse the decrement.

The agent commits to a selected offer, receiving a signed URL for content delivery. A Broker calls this RPC too: once per Exchange, when it re-packages a purchase made through BrokerService.ExecuteTransaction.

POST /fora.v1.ExchangeService/ExecuteTransaction
Content-Type: application/json
{
"ver": "1.0",
"idempotency_key": "tx-req-c7d1",
"requester": {
"id": "research-bot",
"domain": "buyer.example.com",
"type": "REQUESTER_TYPE_AGENT",
"scopes": ["*"]
},
"items": [
{
"offer": {
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"exchange": "exchange.ssp-example.com",
"title": "The Future of AI Infrastructure",
"pricing": {
"model": "PRICING_MODEL_PER_UNIT",
"rate": "0.00002",
"currency": "USD",
"unit_cost": "0.00002",
"estimated_quantity": 2500,
"unit": "tokens"
},
"delivery_method": "DELIVERY_METHOD_INSTRUCTIONS",
"expires_at": "2026-03-14T02:30:00Z",
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "a1b2c3d4e5f6...",
"signature_algorithm": "EdDSA"
}
}
]
}

Each item’s offer is the full signed Offer reflected back exactly as received at discovery; a single-offer transaction is the degenerate one-element items list. The Exchange verifies each item’s offer.signature over the presented Offer bytes to confirm the offer has not been tampered with. The Exchange is stateless — it verifies the self-contained signed Offer rather than storing offers.

The request may also carry agent_request_acceptance — the agent’s detached Ed25519 signature over the complete ordered set of offers it committed to, across all Exchanges. The SDK clients attach it whenever the offer names its Exchange. It is what lets an Exchange receiving a Broker-projected subrequest prove the items are the exact in-order subset the agent signed — see Projected execute requests. The field is optional for wire compatibility: an older client that omits it keeps per-item execution semantics and simply gets no request-level claim.

{
"ver": "1.0",
"items": [
{
"transaction_id": "txn-mp-93a7f2",
"billing_id": "bill-93a7f2-001",
"resource_title": "The Future of AI Infrastructure",
"retrieval_endpoint": "https://cdn.fora-protocol.org/server/premium/ai-infrastructure.html?Expires=1773451434&Key-Pair-Id=KXYZ&Signature=abc123...",
"cost": {
"amount": "0.05",
"currency": "USD",
"unit_cost": "0.00002"
},
"delivery_method": "DELIVERY_METHOD_INSTRUCTIONS",
"reporting_obligation": {
"required": true,
"window": "86400s",
"required_fields": ["transaction_id", "function", "consumed_quantity"]
},
"expires_at": "2026-03-14T02:30:00Z"
}
],
"agent_identity_hash": "NzbLsXh8..."
}

For a metered item, cost.amount is the purchase charge: the offer’s estimated_quantity × rate, here 2,500 tokens × $0.00002 = $0.05, or one unit’s rate when the offer states no estimate. The charge is final (see Charging a metered purchase).

The retrieval_endpoint is a CDN signed URL bound to agent_identity_hash. A bearer-only CDN serves it on signature + expiry alone (the URL signature + short TTL + TLS); a capable edge function additionally requires the agent to present its public key and an RFC 9421 signature, verifying the binding offline. See Signed URL Verification.

Every decision about an item is answered in the response body. An item the Exchange denies is a TransactionResultItem with denial_reason set, in a response that returns OK. This holds whatever the item count: a one-item purchase that is denied is a successful response whose only item is denied, and it is never turned into a non-OK error.

{
"ver": "1.0",
"items": [
{
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"denial_reason": "DENIAL_REASON_OFFER_EXPIRED"
}
]
}

A denied item was not purchased, so it carries no retrieval_endpoint and no charge. A purchase is never denied for a restriction: restrictions are declared terms the parties agree to, and the Exchange never enforces them, so DENIAL_REASON_RESTRICTION_NOT_SATISFIED and restriction_mismatches are deprecated and never set.

A non-OK error carrying ErrorDetail.transaction_denial is used only when the Exchange refuses the whole request and decides no item. That happens for a reason about the caller that would deny every item alike: the caller has no account (DENIAL_REASON_ACCOUNT_NOT_REGISTERED), its account is not active (DENIAL_REASON_ACCOUNT_INACTIVE), it has overdue reports (DENIAL_REASON_REPORTING_OVERDUE), or it is rate limited (DENIAL_REASON_RATE_LIMITED). Nothing is purchased. Other errors keep their own classes: a malformed request is invalid_argument, and a request signature that does not verify is unauthenticated.

Totals across currencies. total_cost is the exact sum of the purchased items’ costs in their one shared currency. When the purchased items span more than one currency, total_cost is unset: amounts are never summed across currencies, and currency conversion is out of scope for this version. Each item’s own cost still carries its amount and currency.

idempotency_key is scoped to the agent that authenticated the request, its authenticated key directory, which is also what its account belongs to. On a request the agent signed itself, the request signature authenticates it; on a purchase a Broker re-packaged, the agent’s acceptances do. The route does not change the scope: the same key from the same agent names the same purchase whether it arrives directly or through any Broker the Exchange admits. A key that another agent used, or that was used at another tenant, is a different key and never a collision.

Before it creates idempotency state for a key, or answers from it, the Exchange verifies the agent’s authorization: the request signature on a direct request, the acceptances on a relayed purchase, and agent_request_acceptance with its projection whenever it is present. A request that does not prove the agent’s authorization creates no idempotency state and is answered from none.

  • The same key with the same request (the same items in the same order) is a replay, whichever route it took. The Exchange answers it with the stored result, the delivery binding it recorded included, and executes nothing again.
  • The same key with a different request (different items, or the same items in another order) is refused with the Connect code already_exists, carrying an ErrorDetail with no typed reason. Nothing is executed, and the stored result is unchanged. A new purchase takes a new key, and so new acceptances, because the acceptances sign the key; that includes a new attempt after a stored denial.

The same rules apply to UsageReport.idempotency_key and DisputeRequest.idempotency_key.

  1. Validate request (proto validation)
  2. Verify the sender’s RFC 9421 HTTP Message Signature (alg=ed25519) in the request headers — the agent’s on a direct request, the Broker’s on a sub-request the Broker re-packaged. On a direct request, also require requester.domain to name the directory the covered Signature-Agent member names. On a re-packaged sub-request the agent is authenticated by its body acceptances instead: each item’s AgentAcceptance is verified against an Ed25519 key in the directory requester.domain names, an acceptance whose canonical bytes name an empty requester is refused, and a delegation’s holder binding (cnf.jkt) is checked against the key the item’s AgentAcceptance verifies under, not against the Broker
  3. Verify agent_request_acceptance when present: the signature, and that this request’s items are the complete in-order projection of the signed set addressed to this Exchange. A request whose items are not exactly that projection (an item dropped, added or reordered) has every item denied with DENIAL_REASON_SIGNATURE_INVALID, in the body: the agent’s signature does not cover what arrived. It is not an invalid_argument error. This runs before the idempotency lookup — request-level idempotency state is never created or served for a request whose set claim has not been proven, otherwise a mutated request could consume the key and a later honest retry would replay its result
  4. Check the idempotency key, scoped to the agent: a replay of the same request returns the stored result, and the same key with different items is refused with already_exists (see Idempotency)
  5. Verify each item’s offer signature over the presented offer bytes; an item whose signature does not verify is denied with DENIAL_REASON_SIGNATURE_INVALID, in the body
  6. Authorize billing (BillingAdapter.Authorize) — or skip for subscription offers
  7. Write transaction to WAL (must succeed before signing URL)
  8. Generate the Ed25519 signed delivery URL (or a CloudFront RSA canned policy, per the tenant’s signing scheme)
  9. Create reporting obligation with deadline

For batch transactions, use the items array:

{
"ver": "1.0",
"idempotency_key": "tx-batch-01",
"requester": {
"id": "research-bot",
"domain": "buyer.example.com",
"type": "REQUESTER_TYPE_AGENT",
"scopes": ["*"]
},
"items": [
{
"offer": {
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.00002", "currency": "USD", "unit_cost": "0.00002", "estimated_quantity": 2500, "unit": "tokens" },
"expires_at": "2026-03-14T02:30:00Z",
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "a1b2c3...",
"signature_algorithm": "EdDSA"
}
},
{
"offer": {
"offer_id": "89636cda-93f6-460e-af7f-2b6aef0f491b",
"exchange": "exchange.ssp-example.com",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.00002", "currency": "USD", "unit_cost": "0.00002", "estimated_quantity": 2500, "unit": "tokens" },
"expires_at": "2026-03-14T02:30:00Z",
"terms": [
{
"semantics": "TERM_SEMANTICS_ENUMERATED"
}
],
"signature": "d4e5f6...",
"signature_algorithm": "EdDSA"
}
}
]
}
{
"ver": "1.0",
"items": [
{
"offer_id": "9d5735d7-1f17-4801-8dcf-7cac0b7b32b0",
"transaction_id": "txn-mp-batch-001",
"billing_id": "bill-batch-001",
"retrieval_endpoint": "https://cdn.fora-protocol.org/server/premium/ai-infrastructure.html?Signature=...",
"cost": { "amount": "0.05", "currency": "USD" },
"expires_at": "2026-03-14T02:30:00Z"
},
{
"offer_id": "89636cda-93f6-460e-af7f-2b6aef0f491b",
"transaction_id": "txn-mp-batch-002",
"billing_id": "bill-batch-002",
"retrieval_endpoint": "https://cdn.fora-protocol.org/server/premium/gpu-shortage.html?Signature=...",
"cost": { "amount": "0.05", "currency": "USD" },
"expires_at": "2026-03-14T02:30:00Z"
}
],
"total_cost": { "amount": "0.10", "currency": "USD" },
"agent_identity_hash": "NzbLsXh8..."
}

Each item gets its own transaction_id, billing_id, and signed URL. Batch transactions are non-atomic — individual items can fail independently, and each denied item carries its denial_reason in the body. total_cost is set here because both items are priced in USD; it would be unset if they spanned two currencies.

BrokerService.ExecuteTransaction buys offers from any number of Exchanges in one call. The agent sends the same TransactionRequest it would send an Exchange — every item with its AgentAcceptance, plus one AgentRequestAcceptance over all items — signed for the Broker’s URL.

POST /fora.v1.BrokerService/ExecuteTransaction
Content-Type: application/json

The Broker re-packages the purchase:

  1. It verifies the agent’s request signature, and checks that requester.domain is the agent’s verified signing directory (the host of the origin the agent’s covered Signature-Agent member names).
  2. It groups the items by each signed offer’s exchange. It checks that it can route to each Exchange and approves it for purchases before it sends anything.
  3. It sends one ExchangeService.ExecuteTransaction per Exchange, signed with its own key. Each sub-request carries that Exchange’s items in request order with their acceptances, the agent’s AgentRequestAcceptance unchanged, the agent’s requester and the agent’s idempotency_key unchanged. Each Exchange verifies the agent’s consent from the body, against the keys in the directory requester.domain names: the Broker’s signature says only that the call comes from the Broker, and the acceptances are the only agent signatures the Exchange sees. An item whose AgentAcceptance does not verify under those keys is denied in the body with DENIAL_REASON_SIGNATURE_INVALID, as for the AgentRequestAcceptance; the whole request is not refused.
  4. It combines the answers into one BrokerTransactionResponse.

Re-packaging and combining are safe because each item is atomic and its integrity is per resource. Each offer carries the signature of the Exchange that issued it, covering price, terms, expiry and exchange. Each acceptance binds the agent to that one offer, the requester and the idempotency key. Each item’s result comes from the Exchange that owns the resource. The Broker therefore assembles independent per-resource purchases, and it cannot change what an item buys, from whom, at what price or for whom without breaking that offer’s signature or the agent’s acceptance. The AgentRequestAcceptance also stops it dropping, adding or reordering items within one Exchange’s sub-request. No cross-item integrity is needed. On the response side, the one signed value in a result item is retrieval_endpoint, which the issuing Exchange signed. The other fields are the Broker’s unsigned report of what each Exchange answered; the charge itself is still bounded by the signed offer the Exchange verified.

{
"ver": "1.0",
"items": [
{
"offer_id": "6edb0ed0-6f7f-4fb2-877b-ea7fa271ad07",
"transaction_id": "txn-a-001",
"billing_id": "bill-a-001",
"retrieval_endpoint": "https://cdn.publisher.example/a1?Signature=...",
"cost": { "amount": "0.05", "currency": "USD" }
},
{
"offer_id": "7a9482de-d0d2-4bfb-ba99-c2506dc2bc91",
"refusal": {
"party": "exchange-b.example",
"code": "permission_denied",
"detail": {
"domain": "fora.v1.ExchangeService",
"transaction_denial": {
"reason": "DENIAL_REASON_ACCOUNT_NOT_REGISTERED",
"exchange": "exchange-b.example"
}
}
}
},
{
"offer_id": "5c43dd75-9960-4ebf-bbbd-06d4f9014a40",
"transaction_id": "txn-a-002",
"billing_id": "bill-a-002",
"retrieval_endpoint": "https://cdn.publisher.example/a2?Signature=...",
"cost": { "amount": "0.40", "currency": "EUR" }
}
],
"exchanges": [
{ "exchange": "exchange-a.example", "offer_ids": ["6edb0ed0-6f7f-4fb2-877b-ea7fa271ad07", "5c43dd75-9960-4ebf-bbbd-06d4f9014a40"], "agent_identity_hash": "NzbLsXh8..." },
{ "exchange": "exchange-b.example", "offer_ids": ["7a9482de-d0d2-4bfb-ba99-c2506dc2bc91"] }
],
"totals": [
{ "amount": "0.05", "currency": "USD" },
{ "amount": "0.40", "currency": "EUR" }
]
}
  • items has one entry per request item, in request order. A success, or a per-item denial, is the Exchange’s TransactionResultItem unchanged.
  • Upstream refusals ride in the body. When an Exchange refuses the Broker’s whole sub-request, or does not answer it, the Broker does not turn that into an error of its own. Each affected item carries refusal: the Exchange, the Connect code it answered with, and its ErrorDetail unchanged. The other Exchanges’ results come back unchanged. DENIAL_REASON_CONTENT_UNAVAILABLE is not a catch-all for upstream failures.
  • exchanges has one ExchangeOutcome per Exchange contacted. Its agent_identity_hash is what that Exchange’s retrieval URLs are bound to.
  • An item whose refusal carries the code unavailable or deadline_exceeded has an unknown outcome: the Exchange may have bought it before the answer was lost, whether it gave no answer or answered that code. With any other code the Exchange decided no item, and the item was not purchased.
  • totals sums only the charged items (those with neither denial_reason nor refusal), one Cost per currency. Amounts are never summed across currencies. An item with an unknown outcome is not counted, so totals is then a lower bound of the charge.
  • When the Broker itself does not understand a key the agent’s request lists in ext_critical, it answers OK with every item denied as DENIAL_REASON_UNKNOWN_CRITICAL_EXTENSION and contacts no Exchange, so exchanges and totals are empty. It is the one denial the Broker makes itself; every other denial_reason is an Exchange’s.
  • DENIAL_REASON_RELAY_NOT_ACCEPTED on an item means the provider that sells that item’s resource at that Exchange does not accept purchases relayed by a Broker. It is decided per item, by each offer’s provider, and answered in the body: other items of the same sub-request may succeed. It is never a refusal of the whole sub-request. Buy that offer directly.
  • A sub-request whose items are not exactly the agent’s signed AgentRequestAcceptance projected onto that Exchange has every item denied with DENIAL_REASON_SIGNATURE_INVALID, in the body, and nothing is purchased.

The Broker’s own refusals are non-OK Connect errors carrying an ErrorDetail, and nothing was bought: an invalid agent signature is unauthenticated with request_auth_failure; a requester.domain that is not the agent’s signing directory is unauthenticated with request_auth_failure SIGNATURE_INVALID; a malformed request is invalid_argument; an Exchange the Broker cannot route to or does not approve is failed_precondition, with metadata["exchange"] naming it.

A retry is safe. The Broker forwards the agent’s idempotency_key unchanged to every Exchange, so each Exchange answers a repeated sub-request from its stored result. A refusal with the code unavailable or deadline_exceeded is an ambiguous outcome, and retrying the same request with the same key is how the agent resolves it, on any route: through the same Broker, another one, or directly to each Exchange.

{
"ver": "1.0",
"items": [
{
"transaction_id": "txn-sub-001",
"billing_id": "bill-sub-001",
"retrieval_endpoint": "https://cdn.fora-protocol.org/server/premium/ai-infrastructure.html?Signature=...",
"cost": { "amount": "0", "currency": "USD" },
"subscription_id": "SUB-12345",
"subscription_unit_value": { "amount": "0.05", "currency": "USD", "unit_cost": "0.00002" },
"reporting_obligation": { "required": true, "window": "86400s", "required_fields": ["transaction_id", "function", "consumed_quantity"] }
}
],
"agent_identity_hash": "NzbLsXh8..."
}

After content has been used, the agent submits a usage report. When ReportingObligation.required is set on the offer, reporting is mandatory — failure to report within the window may trigger DENIAL_REASON_REPORTING_OVERDUE on subsequent transactions.

POST /fora.v1.ExchangeService/ReportUsage
Content-Type: application/json
{
"ver": "1.0",
"exchange": "exchange.ssp-example.com",
"idempotency_key": "ur-e4f2a1",
"transaction_id": "txn-mp-93a7f2",
"billing_id": "bill-93a7f2-001",
"usage": {
"function": ["ai-input"],
"subfn": ["rag"],
"consumed_quantity": 2400,
"displayed_to_user": true,
"citation_included": true
},
"timestamp": "2026-03-14T01:45:00Z",
"assets": [{
"uri": "https://cdn.fora-protocol.org/premium/ai-infrastructure.html",
"title": "The Future of AI Infrastructure",
"package_id": "PKG-TC-AI-INFRA"
}]
}
{
"ver": "1.0",
"report_id": "rpt-e4f2a1-001"
}

A successful response carries the Exchange-assigned report_id (the handle the agent later references in DisputeRequest). A rejected report is not a success body — it travels as a non-OK transport error carrying ErrorDetail.usage_report_rejection (e.g. USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED, USAGE_REPORT_REJECTION_REASON_MISSING_REQUIRED_FIELDS).

  1. The report is well formed. A malformed report is refused with USAGE_REPORT_REJECTION_REASON_MALFORMED and the Connect code invalid_argument
  2. Required fields present (as defined by ReportingObligation.required_fields)
  3. Report received within the reporting window (typically 24 hours). A report that arrives after the window closed is refused with USAGE_REPORT_REJECTION_REASON_WINDOW_EXPIRED and the Connect code failed_precondition: the typed reason names the time cause
  4. The transaction exists for this agent, and billing_id matches. A report whose billing_id is not the transaction’s is malformed: USAGE_REPORT_REJECTION_REASON_MALFORMED, invalid_argument. A report for a transaction recorded under a different agent’s account is answered exactly as for an unknown transaction, USAGE_REPORT_REJECTION_REASON_TRANSACTION_NOT_FOUND with not_found, so the answer does not reveal that the transaction exists

The reported quantity is unrestricted. A consumed quantity that differs from the offer’s estimate is not a validation failure, above or below, and no check on the quantity refuses a report. The report is a record of the quantity consumed. Monitoring how far reports fall from estimates is the Exchange operator’s own business, not part of the protocol.

A PER_UNIT price is metered: it is charged per unit. The publisher states the rate and the unit, and the offer may also state an estimate. The offer signature covers them, so accepting the offer is consenting to them:

SymbolFieldMeaning
RrateThe price of one unit
Eestimated_quantityThe estimate, in unit. Optional. A PER_UNIT offer may state a rate with or without an estimate; whether to state one is the publisher’s decision. An estimate that is stated is positive (offer.metered.estimate_positive), and the SDK verifiers reject a zero one

A metered purchase charges E × R, or one unit, 1 × R, when the offer states no estimate. That is the item’s cost, and the charge is final. With the offer above (E = 2,500 tokens, R = $0.00002) the purchase charges $0.05. A disagreement about the quantity consumed is a dispute, which this version does not define further.

The FORA protocol covers discovery, negotiation, and transaction execution. Content delivery is outside protocol scope — it is handled by the Exchange implementation and the provider’s CDN.

The TransactionResponse carries two relevant fields:

  • DeliveryMethod — a hint indicating whether the Exchange is providing content directly or providing retrieval instructions
  • retrieval_endpoint — the signed URL the agent uses to fetch content

How the agent actually retrieves content (signed URLs, access tokens, inline content) depends on the Exchange and CDN implementation, not the protocol.

FORA supports DELIVERY_METHOD_STREAMING for real-time resource access (WebSocket, SSE, HLS, Icecast). The signed URL points to a streaming endpoint. Stream mechanics (heartbeat, reconnection, concurrent connection limits) are managed by the provider, not the FORA protocol.

Session lifecycle:

  1. ExecuteTransaction returns a signed URL with expiry (e.g., 2 hours)
  2. Agent connects to the stream endpoint
  3. Agent receives data continuously
  4. URL expires — agent re-requests through the same FORA flow
  5. ReportUsage: consumed_quantity in minutes/hours, consumed_unit matching the offer’s unit

Streaming applies to live broadcasts, quote feeds, monitoring feeds, and any resource where content is delivered continuously rather than as a single fetch.

ResourceIdentity.resource_mutability signals whether a resource’s content is stable across time:

ValueMeaningHash Behavior
RESOURCE_MUTABILITY_STATICContent does not change (articles, papers, legislation)Agent SHOULD verify content_hash. Mismatch is disputable.
RESOURCE_MUTABILITY_DYNAMICContent updates between offer and fetch (credit reports, drug databases)Agent MUST NOT auto-dispute hash mismatch. Use data_as_of for freshness.
RESOURCE_MUTABILITY_LIVENo content exists at offer time (streams, broadcasts)No content_hash applicable. Metering is time-based.

The Exchange MUST set resource_mutability on every ResourceIdentity. For DYNAMIC resources, the Exchange MUST set data_as_of on the Offer. For LIVE resources, the Exchange MUST NOT set content_hash.

Three independent records — CDN logs, Exchange transactions, usage reports — that must agree. This provides stronger reconciliation than ad-tech’s two-sided model.

FORA uses at-most-once with reconciliation, same as OpenRTB:

  • Idempotency keys on every TransactionRequest, scoped to the agent whatever the route (prevent double-charge on retry, through a Broker or directly; a key reused with different items is refused with already_exists)
  • “Authorize first, deliver second” ordering (do not issue signed URL until billing succeeds)
  • Reconciliation via UsageReport — if content was not actually used, the report says so
  • Credits and refunds come from dispute resolution (DisputeTransaction, below), which every Exchange implements; the protocol standardizes the dispute signal, not the settlement between the parties

When delivered content does not match what was promised, the agent files a dispute. The agent MUST file a UsageReport before disputing — DisputeRequest.report_id is required.

POST /fora.v1.ExchangeService/DisputeTransaction
Content-Type: application/json
{
"ver": "1.0",
"exchange": "exchange.ssp-example.com",
"idempotency_key": "dsp-20260318-abc123",
"transaction_id": "txn-mp-93a7f2",
"billing_id": "bill-93a7f2-001",
"reason": "DISPUTE_REASON_CONTENT_MISMATCH",
"description": "Content hash does not match attested hash",
"received_content_hash": "sha256:e5f6a7b8...",
"received_hash_method": "sha256",
"report_id": "rpt-20260318-xyz789"
}
{
"ver": "1.0",
"dispute_id": "case-20260318-def456",
"estimated_resolution": "1s",
"status": "DISPUTE_STATUS_AUTO_RESOLVED",
"resolution": "RESOLUTION_TYPE_CREDIT"
}

Every Exchange implements DisputeTransaction; it is part of the core protocol, not an optional capability. A successful response means the dispute was accepted for processing (dispute_id + status + resolution). A refusal to file is not a success body — it travels as a non-OK transport error carrying ErrorDetail.dispute_failure, whose DisputeFailureReason names the one check the filing failed:

ReasonThe filing failed because
DISPUTE_FAILURE_REASON_TRANSACTION_NOT_FOUNDtransaction_id names no transaction this Exchange executed for the caller
DISPUTE_FAILURE_REASON_REPORT_NOT_FILEDNo accepted usage report precedes it: report_id is empty, unknown, or names a report for another transaction
DISPUTE_FAILURE_REASON_WINDOW_EXPIREDThe transaction’s dispute window, set by the Exchange, has closed
DISPUTE_FAILURE_REASON_DUPLICATEA dispute was already filed for this transaction. A retry of the same filing under the same idempotency_key is a replay and returns the original DisputeResponse
DISPUTE_FAILURE_REASON_INELIGIBLEThe transaction’s state admits no dispute under the Exchange’s rules
DISPUTE_FAILURE_REASON_UNKNOWN_CRITICAL_EXTENSIONThe filing’s ext_critical names a key the Exchange does not understand. Refused with invalid_argument (see Extension Profiles)

A dispute that is accepted and later found unsupported ends as RESOLUTION_TYPE_REJECTED; it is not a refusal to file. The Exchange resolves most accepted disputes automatically in under 1 second using CDN logs and cryptographic evidence. See Dispute Resolution for the three-tier resolution system, auto-credit rules, and the complete dispute lifecycle.

Offers may include lightweight preview URLs for pre-transaction evaluation. The Exchange holds only the URL (50–200 bytes); the provider’s CDN serves the actual bytes. Agents fetch previews only when evaluating offers — not on every discovery query.

message Preview {
string url = 1; // CDN-hosted preview URL
string media_type = 2; // MIME type ("image/jpeg", "audio/mpeg", "text/plain")
optional int32 width = 3; // Pixels (images/video)
optional int32 height = 4;
optional int32 duration = 5; // Seconds (audio/video clips)
optional string size = 6; // "thumbnail", "preview", "sample"
}
Content TypePreviewWhat the URL Points To
ImageWatermarked thumbnailSmall JPEG, 150–450px, provider-watermarked
VideoShort clip10–30 second MP4/WebM, watermarked
AudioShort clip15–30 second MP3, low-bitrate or watermarked
TextSnippet or abstractFirst 200 words or abstract as text/plain
Data / recordsSample record1–3 sample rows as application/json
StreamOptional frame captureOr none — streams are priced by time, not by content
{
"offer_id": "9bb01145-326e-42a9-b2ac-058e3114bdfc",
"exchange": "exchange.ssp-example.com",
"title": "Climate Summit Photo - Reuters",
"pricing": { "model": "PRICING_MODEL_PER_UNIT", "rate": "0.50", "currency": "USD", "estimated_quantity": 1, "unit": "accesses" },
"previews": [
{
"url": "https://cdn.reuters.com/preview/150/climate-summit.jpg",
"media_type": "image/jpeg",
"width": 150, "height": 100,
"size": "thumbnail"
},
{
"url": "https://cdn.reuters.com/preview/450/climate-summit.jpg?sig=abc&exp=300",
"media_type": "image/jpeg",
"width": 450, "height": 300,
"size": "preview"
}
]
}

This follows the universal content exchange pattern:

  • Shutterstock: small_thumb.url (100px, unwatermarked), preview.url (450px, watermarked), preview_1500.url (1500px, watermarked)
  • Spotify: preview_url — URL to a 30-second MP3 clip
  • IIIF: Parameterized URL template — client requests any size
  • OpenRTB: img.url + img.w + img.h per asset

Nobody puts image bytes in offer metadata. The Exchange holds a URL string; the CDN holds the pixels.