Clear ExchangeDocs Live demo

Entitlement token format and verification

When an agent accepts an offer, the exchange issues an entitlement: a signed bearer grant for one piece of content, at one usage tier, until it expires. The agent presents it to retrieve the content and to report telemetry. Anyone can verify it offline with the exchange's public keys.

Code: src/entitlement/token.ts (issue and verify), src/entitlement/keys.ts (signer and key resolver interfaces).

Format

A compact JWS (RFC 7515) signed with EdDSA over Ed25519 (RFC 8037), with registered JWT claims (RFC 7519) plus one private claim, cx, for the licence scope.

Header:

{ "alg": "EdDSA", "typ": "cx-entitlement+jwt", "kid": "cx-MhcT0-9Ns0Yccd2i" }

Claims (decoded from a real token):

{
  "iss": "http://localhost:8080",
  "sub": "my-agent",
  "aud": "http://localhost:8080",
  "jti": "ent_9989d9af-4391-45ba-b6a8-5aac031eb7cc",
  "iat": 1793184677,
  "nbf": 1793184677,
  "exp": 1793185577,
  "cx": {
    "v": 1,
    "content_id": "fn-2026-0153",
    "publisher_id": "pub_fieldnote",
    "supply_id": "local-catalogue",
    "usage": "display_snippet",
    "terms": {
      "citation_required": true,
      "attribution": "Fieldnote Science",
      "max_snippet_chars": 400,
      "retention_seconds": 604800,
      "ai_training": "prohibited"
    },
    "key_terms": {
      "v": 1,
      "commercial_use": true,
      "attribution_required": true,
      "attribution": "Fieldnote Science",
      "share_alike": false,
      "caching": { "allowed": true, "max_seconds": 604800 },
      "reuse_across_end_users": false,
      "display": "snippet",
      "max_display_chars": 400,
      "ai_training": "prohibited",
      "content_licence_id": null,
      "indemnity": { "status": "not_defined", "note": "Clear Exchange has not yet published an indemnity or liability position. None is given or implied by this offer." },
      "summary": "Commercial use OK; credit Fieldnote Science; snippet up to 400 characters; cache 1 week; one end user per retrieval; no AI training."
    },
    "price": { "amount_micros": 5500, "currency": "USD" },
    "clearing": { "publisher_price_micros": 5000, "fee_micros": 500, "fee_bps": 1000 },
    "content_hash": "sha256:a1c66a16f0983ea78a692d54e809369efe2dfbf15dff8bfe3b506a4eb7f6dc7d",
    "rate_card_ref": "rc_fieldnote@v1",
    "offer_id": "off_0f0ef321-f551-42cc-bb22-cf95b7f6be9b",
    "session_id": "52bb3cae-fe4f-4d40-8d5d-322f8c9d1c0e"
  }
}
ClaimMeaning
iss, audBoth the exchange's PUBLIC_URL (https://app.clearexchange.ai on the demo, whichever hostname the request came in on; see Hostnames).
subThe agent_id the entitlement was issued to.
jtiEntitlement id (ent_<uuid>). Appears as entitlement_id in API responses, as license_ref in telemetry, and in the ledger.
iat, nbf, expIssued at, not before, expiry (Unix seconds). exp = iat + ENTITLEMENT_TTL_SECONDS (default 900).
cx.vScope version, 1.
cx.content_idThe one item granted. For http-offer sources it is <source id>~<publisher's content id>.
cx.publisher_id, cx.supply_idWho supplies it, and through which source.
cx.usageTier granted. It grants itself and every narrower tier.
cx.key_termsThe key licence terms sold: commercial use, attribution, share-alike, caching and duration, reuse across end users, display rights, and a one-line summary. Signed like every other claim. Absent from tokens issued before it was added, so treat it as optional.
cx.termsThe resolved licence terms that were sold (the rate card's defaults merged with the tier's overrides). See Rate cards.
cx.priceAll-in price the agent pays, in micros.
cx.clearingThe split fixed at offer time: publisher price and fee (sums to cx.price).
cx.content_hashOptional. sha256:<hex> of the full text the offer described. Retrieval fails with 502 supply_content_mismatch, and nothing is charged, if the source serves different text.
cx.rate_card_refrate_card_id@vN of the card that priced it.
cx.offer_id, cx.session_idThe accepted offer and the SPUR telemetry session.

How the exchange verifies

verifyEntitlement() does these checks in order and stops at the first failure:

  1. Three base64url parts, otherwise malformed.
  2. Header alg must be EdDSA and typ must be cx-entitlement+jwt, otherwise unsupported_alg. The algorithm is pinned; the token never chooses it, which rules out alg: none and algorithm confusion.
  3. kid must name a known key, otherwise unknown_key.
  4. The Ed25519 signature must verify over header.payload, otherwise bad_signature. No claim is trusted before this step.
  5. Claims must have the expected shape and cx.v = 1, otherwise malformed.
  6. iss and aud must equal PUBLIC_URL (wrong_issuer, wrong_audience).
  7. Time window: now ≥ nbf (not_yet_valid) and now < exp (expired). There is no clock leeway by default. Telemetry reporting accepts tokens up to TELEMETRY_GRACE_SECONDS past exp; content retrieval never does.
  8. Scope, when the caller asks: cx.content_id must match (wrong_content), and cx.usage must be at least the tier needed (insufficient_usage).

On the HTTP API these failures become 401 entitlement_<reason>, except wrong_content and insufficient_usage, which are 403 (see Errors). POST /v1/entitlements/verify returns the same reasons in a 200 body.

Verifying offline

Publishers and other third parties can verify without calling the exchange:

  1. Fetch GET {exchange}/.well-known/jwks.json (cache it; refetch on an unknown kid).
  2. Split the token, decode the header, and check alg == "EdDSA" and typ == "cx-entitlement+jwt".
  3. Select the JWK whose kid matches and verify the Ed25519 signature over the ASCII bytes of <header>.<payload>.
  4. Only then decode the claims, and check iss, aud, nbf, exp, and that cx.content_id and cx.usage cover what is being requested.

A Node example with only node:crypto (the harness does the same in harness/jws.ts):

import { createPublicKey, verify } from "node:crypto";

const EXCHANGE = "https://app.clearexchange.ai";
const jwks = await (await fetch(`${EXCHANGE}/.well-known/jwks.json`)).json();

export function verifyEntitlement(token: string, contentId: string) {
  const [h, p, s] = token.split(".");
  const header = JSON.parse(Buffer.from(h, "base64url").toString());
  if (header.alg !== "EdDSA" || header.typ !== "cx-entitlement+jwt") throw new Error("unsupported alg");
  const jwk = jwks.keys.find((k: { kid: string }) => k.kid === header.kid);
  if (!jwk) throw new Error("unknown key");
  const key = createPublicKey({ key: jwk, format: "jwk" });
  if (!verify(null, Buffer.from(`${h}.${p}`), key, Buffer.from(s, "base64url"))) throw new Error("bad signature");
  const claims = JSON.parse(Buffer.from(p, "base64url").toString());
  const now = Math.floor(Date.now() / 1000);
  if (claims.iss !== EXCHANGE || claims.aud !== EXCHANGE) throw new Error("wrong issuer or audience");
  if (now < claims.nbf || now >= claims.exp) throw new Error("not valid now");
  if (claims.cx.content_id !== contentId) throw new Error("wrong content");
  return claims;
}

Any JOSE library that supports EdDSA/Ed25519 works the same way; pin the algorithm rather than reading it from the token.

An http-offer publisher receives the agent's entitlement in the body of its retrieve call and can verify it this way before returning text (see Adding a source).

Keys

  • The demo signs with one Ed25519 key from the SIGNING_KEY_PEM secret (PKCS#8 PEM; npm run keygen prints one). The kid is cx- plus the first 16 characters of the public key's x, so it stays the same across restarts and deploys.
  • Locally, with neither SIGNING_KEY_PEM nor SIGNING_KEY_FILE set, an ephemeral key is generated at startup, and tokens stop verifying after a restart. Production refuses to start without a stable key.
  • Replacing the key invalidates every outstanding entitlement. There is no multi-key rotation yet.
  • Token code depends only on the Signer and VerificationKeyResolver interfaces, so an attested signer can replace LocalEd25519Signer later without changing token logic (see Roadmap).

What an entitlement is not

  • For a demo agent (no credentials) it is a plain bearer token: whoever holds it can retrieve the content until exp. For an agent that authenticated with an API key or signed requests it is bound to that agent: the claims carry cx.agent (agent_id, label, assurance of api_key or web_bot_auth, and the signing key_thumbprint), and retrieval and telemetry also need that agent's credentials, so a leaked token cannot be replayed by another agent. See Agent authentication.
  • It is not a payment. The ledger records the price; no money moves.
  • One offer yields at most one entitlement (409 offer_already_accepted). Retrieving again with the same entitlement is not charged again.