# Vendor manifest spec 0.2

An open specification for how an API vendor tells an agent's account layer what it sells, which documents a human must accept, and how an account is created with that human's signed consent. Three JSON Schemas, a reference endpoint and a conformance check, under the Apache-2.0 license.

- [manifest.schema.json](/schemas/0.2/manifest.schema.json)
- [signup-request.schema.json](/schemas/0.2/signup-request.schema.json)
- [consent-record.schema.json](/schemas/0.2/consent-record.schema.json)

## Discovery

1. `https://{vendor-domain}/.well-known/suffect.json`: the manifest this spec defines.
2. Optional: the vendor's MCP server lists Suffect's authorization server in the `authorization_servers` array of its RFC 9728 protected-resource metadata, so a client that hits the vendor's 401 lands on the consent sheet.
3. Optional: an entry in the vendor's Agentic Resource Discovery `ai-catalog.json` pointing at the manifest.

The manifest must be served from the vendor's own domain over HTTPS. Suffect refuses a manifest served from a third-party host, so a lookalike cannot speak for a vendor.

## The manifest

| Field | Required | What it is |
| --- | --- | --- |
| `suffect` | yes | the spec version the manifest is written to; minor versions are additive |
| `provider` | yes | name, domain, legal name, support URL and kind |
| `terms` | yes | every document a human must accept: kind, URL, version, the sha256 of the normalized text, what it requires of the human, the notices it carries and the clauses those rest on |
| `pricing` | no | currency, plans, packs and the pricing page |
| `signup` | no | the level, the endpoint and its authentication, the contact fields, idempotency |
| `credential` | no | type, header, scheme, scopes, rotation and the proxy base URL |
| `budget` | no | a webhook for budget events, and whether usage events are sent |
| `rails` | no | card, mpp or x402 |
| `contact` | no | security and partnerships addresses |
| `resources` | no | optional: each resource with its own access, pricing, acquisition and, when it has them, terms; without it the manifest describes one account resource acquired through signup |

What a manifest declares decides what Suffect can do at the vendor: terms alone let the sheet name the documents by version and hash; terms and pricing make the sheet exact; a signup endpoint makes the signup one call; rails including `mpp` or `x402` will add per-request access once Suffect's payer rails are built. The conformance check prints the level a manifest earns. What each brings, restated for vendors, is on [the vendors page](/vendors#levels).

## How a document is hashed

A terms `sha256` is computed over the normalized text: CRLF and CR become LF, runs of spaces and tabs collapse to one space, each line is trimmed, empty lines are dropped, lines join with LF. An HTML page's text is its main content first (the first `main` element, else the first element with `role="main"`, else the first `article`, else the body) with scripts, styles, the head and the page's header, navigation and footer dropped, block boundaries as line breaks and entities decoded; a PDF's text is its pages in order. The reference implementations are `normalizeTerms` and `stripHtml` in the spec package, the one extractor Suffect's snapshot job and its Worker both run, so a vendor and Suffect compute the same hash for the same page.

## The signup request

`POST {signup.endpoint}` with `Content-Type: application/json`, an `Idempotency-Key` header and a request signature (below). The body:

| Field | Required | What it is |
| --- | --- | --- |
| `suffect` | yes | the spec version |
| `request_id` | yes | unique per vendor; the idempotency key |
| `consent` | yes | the human's signed consent record (the third schema) |
| `attestation` | yes | a JWS Suffect signs over the record's sha256, the request id, your domain and a five-minute expiry |
| `requested` | yes | the plan, packs and budget the human approved |
| `contact` | yes | the fields your manifest lists: an alias email or the human's own, name, country |
| `credential` | yes | the type and scopes the account's key should carry |
| `payment` | no | the rail and, for a card, the issuer's network token |
| `resource` | no | the account resource's slug when the manifest lists more than one |

- **201 Created**: the account id, the credential (type, value, scopes, when it rotates), the accepted terms by version and hash, and a pricing echo
- **202 Accepted**: a status URL; Suffect polls until succeeded, failed or action required with a URL for the human
- **409 Conflict**: `account_exists`, with the existing account id for the same human when the vendor can say
- **422**: `terms_version_stale`, `plan_unavailable`, `budget_below_minimum`, `representation_missing` or `country_unsupported`
- **403**: `attestation_invalid`

## The consent record

What a human approved, signed twice: by the human's passkey and by Suffect's Ed25519 key, and chained to the human's previous record. The attestation that accompanies a signup binds this record's hash to that one request at that one vendor for five minutes; a vendor verifies both against the key set at `https://suffect.com/.well-known/jwks.json` and keeps the record with the account.

| Field | Required | What it is |
| --- | --- | --- |
| `type` | yes | the record's type name |
| `version` | yes | the record format's version |
| `id` | yes | the record's id |
| `issued_at` | yes | when it was issued |
| `issuer` | yes | Suffect, with the key id of its countersignature |
| `subject` | yes | the human, by a stable id, with the representations they made once |
| `approver` | yes | the passkey that approved, and when |
| `agent` | yes | the OAuth client the approval covers |
| `provider` | yes | the vendor's domain and name |
| `scope` | yes | the resources, actions and amounts the approval allows, exactly |
| `terms` | yes | every terms snapshot the record accepts, by kind, URL, version and sha256 |
| `commercial` | yes | the plan or pack and its price |
| `budget` | yes | the cap and its period |
| `data_shared` | yes | name, alias email or own email, country |
| `assent_text` | yes | the sentence the human approved |
| `notices_shown` | yes | every notice the sheet printed |
| `clauses_shown` | no | the clauses printed under the notices, each keyed to a document in terms |
| `proofs` | yes | the passkey's signature and Suffect's Ed25519 countersignature |
| `prev` | yes | the hash of the human's previous record |

## Request signatures

Every request Suffect's automation sends a vendor (signup requests, reads of a manifest and the terms it cites, and the runner's browser on the vendor's hosts) carries an RFC 9421 HTTP Message Signature (Ed25519) over the authority and the `Signature-Agent` header, with a five-minute window, a fresh nonce and the tag `web-bot-auth`, in the profile Cloudflare's verifier takes. The agent's API calls through the gateway carry the vendor's key, not a signature. `Signature-Agent` names the origin that publishes the key directory, a JWK Set of Ed25519 public keys each proven by a signature over the directory's authority, with the key id as the key's thumbprint. A signature proves that the holder of a published key sent this request to this host within this window, and nothing about the human; the record and the attestation name the human.

## Replay and rotation

- `request_id` is unique per vendor; the attestation expires after five minutes; signup requests carry idempotency keys.
- A request signature lives five minutes with a fresh nonce; a verifier that wants replay protection inside the window keeps the nonces it has seen for as long. A signature authorizes nothing by itself.
- The attestation a browser carries is refreshed at most every thirty seconds per job and only while the job is open; a captured one reads the human's record for the rest of its five minutes and nothing more.
- Attestation keys rotate with overlap, `kid` naming the key. Request-signing keys rotate through the directory: published and proven before anything signs with them, kept listed for the directory's max-age plus the longest signature life, then dropped.
- Vendors should rate-limit the signup endpoint per subject and per IP, and treat the alias email as the customer's contact. Suffect sends only the contact fields the manifest lists.

## Conformance

`suffect-spec check <domain>` fetches the manifest from the vendor's own domain and validates it against the schema (`manifest`), hashes every document the manifest cites against the live page (`terms:<kind>`, and `terms:<resource>:<kind>` for a resource's own documents), holds every quoted clause to the fetched text (`clauses:<kind>`), and exercises the signup endpoint (`endpoint`). Suffect's own verification runs the same check and then snapshots each document, so a vendor sees the verdict before publishing.

## Schemas

The three schemas are served at the `$id` each declares, under `/schemas/0.2/` on this site, byte for byte as the spec package ships them: [manifest.schema.json](/schemas/0.2/manifest.schema.json), [signup-request.schema.json](/schemas/0.2/signup-request.schema.json) and [consent-record.schema.json](/schemas/0.2/consent-record.schema.json). The signup request refers to the consent record by its `$id`.

---

Canonical: https://suffect.com/spec. Every page of this site has a Markdown twin at the same path plus `.md`; the index is https://suffect.com/llms.txt.
