# API reference

Generated from the protocol specification, sections 5 and 6. The SDKs call this API for you; you need it only to write a client of your own. Every error points at [Errors](/docs/errors).

## Authentication and API keys

API keys are `sfa_test_<32 base62>` for the sandbox and `sfa_live_<32 base62>` for live. The prefix sets the env. Keys are stored only as hashes.


## Endpoints

| method and path | auth | purpose |
|---|---|---|
| `POST /v1/sandbox` | none (rate-limited per IP) | creates an anonymous sandbox project: no account, email or card. Returns `{project, api_key, claim_url, free_quota, terms_url, privacy_url, notice}` (`free_quota` in records per month; `terms_url` and `privacy_url` are the service's Terms and Privacy notice, which creating a sandbox accepts, and `notice` says so in one line that clients show the person). Clients call it only on an explicit human opt-in (`signafaide init`, the MCP `signafaide_init` tool); recording never calls it (SPEC §8) |
| `GET /v1/project` | key | `{project, env, claimed, name}` of the key, so a client can sign envelopes (§2.2) before its first record is accepted; `claimed` says whether a sandbox now belongs to an organisation (its claim link can no longer be used); `name` is the project's name, which `signafaide status` shows |
| `POST /v1/agents` | key | `{name, public_key, valid_from, proof, rotation?}` (§2.4) returns `{agent, key_id, retired}`. Idempotent. A missing or wrong `proof` or `rotation` is `bad_signature`; a key already registered to another agent, or a `valid_from` out of range, is `invalid_record` |
| `POST /v1/records` | key | `{records:[signed record...]}` (at most 1000) returns `{receipts:[...], heads:[{head, head_sig}]}` |
| `GET /v1/heads/{agent}` | key | returns the current signed head `{head, head_sig}`, or `not_found` when the agent has no records (registered or not). A client whose local chain is gone (an ephemeral disk) continues after it (SPEC §8); on `not_found` (or `unknown_agent` from an older witness) the chain starts at seq 1 |
| `GET /v1/records/{record_hash}` | key | returns `{record, receipt, anchor}`: the signed record, its receipt, and `{anchor, anchor_sig, path}` (path as in §4) once anchored, else null |
| `GET /v1/export?agent=&from=&to=` | key | returns a verification pack (SPEC §7) of the key's project and env: every agent by default, only `agent` when given, records with `from <= envelope.time < to`. The pack names the filter, holds the newest signed head of each agent that has records in it, and carries the service's signed export statement (SPEC §7); all of it is read in one database snapshot. At most 50,000 records; a larger export is `bad_request` and is split with `agent`, `from` and `to` |
| `GET /v1/anchors?after=` | none | public list of anchors: `{anchors:[{anchor, anchor_sig}]}` with `seq > after`, oldest first, at most 100 |
| `GET /v1/usage` | key | `{period, used, quota, contract, plan}`: `quota` is the plan's included records for live keys (a limit only on Free) and the sandbox's for test keys. On Enterprise it is the contract's allowance (`contract: true`), or null when the contract sets none |
| `POST /v1/events` | key | `{name}` where name is `verify` or `export`: the SDK reports that the client ran one (for the service's adoption metrics). Returns 202 |
| `GET /.well-known/signafaide-keys.json` | none | §5 |
| `GET /healthz` | none | `ok` |


## Client header

SDKs, the CLI and the MCP server send `X-Signafaide-Client: <sdk>/<version> (<language> <runtime version>; adapter=<name|none>; agent=<claude-code|codex|cursor|gemini-cli|copilot|windsurf|unknown>; via=<sdk|cli|mcp>)`, optionally with `; strict=1` when strict mode is on. The service parses it leniently and uses it only for usage metrics.


## Errors

**Errors** are `{"error":{"code":"...","message":"one sentence that says how to fix it","docs":"<url>"}}`. The codes are:

| code | status | when |
|---|---|---|
| `chain_conflict` | 409 | the error object also holds the service's head for the conflicting agent: `"head":{agent, seq, record_hash}` (`seq` 0 and `record_hash` null when it has no records) |
| `bad_signature` | 400 | |
| `unknown_agent` | 400 | |
| `content_mismatch` | 400 | |
| `quota_exceeded` | 429 | the Free plan's monthly allowance (or the sandbox's) is used up; paid plans never get it for live records |
| `plan_required` | 402 | the organization's plan does not include the feature, for example `content` on live records on Free. The message names the plan and the console billing URL |
| `invalid_key` | 401 | |
| `invalid_record` | 400 | |
| `rate_limited` | 429 | |
| `not_found` | 404 | the record, agent head or route does not exist |
| `bad_request` | 400 | a query parameter or request body (other than a record) is invalid |

**Client behaviour on errors.** A client sends one request at a time per process. After a `network`, `rate_limited` or 5xx error it backs off (doubling, to at most 60 s). After an error retrying cannot fix (`quota_exceeded`, `invalid_key`, `plan_required`, `bad_signature`, `invalid_record`, `content_mismatch`, `chain_conflict`) it stops sending in the background until its configuration (API key, witness URL, agent keys) changes; an explicit `signafaide flush` always tries again. A malformed API key (§6 key format) is never sent. Outside strict mode, nothing in a recorded call waits for the network or raises because of it.


## Idempotency and ingest rules

re-sending an already accepted record returns its original receipt. In a request that mixes accepted and new records, only the new ones form the new batch.

**Ingest rules** (`POST /v1/records`, `invalid_record` unless noted): `envelope.project` and `envelope.env` are the key's; `envelope.time` is at most `MAX_CLOCK_SKEW` (300 s) after the service's clock and not earlier than the previous record's `time` in that agent's chain; the key is active at `envelope.time` and not revoked at the moment the service receives the record (`bad_signature`).

## Service keys
`GET /.well-known/signafaide-keys.json` returns:

```json
{"keys":[{"key_id":"...","public_key":"...","valid_from":"...","valid_to":null}]}
```

Old keys stay listed for as long as any record they signed may exist.
