> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contracts.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Org keys name the human they act for. Per-person tokens are the seat. Guests never hold keys.

<Warning>
  **Draft — ahead of the backend; nothing here is live yet.** This surface is
  written against the design, not against a running server. There is no
  `api.contracts.io` to call, no key to issue, and every response below is an
  illustration rather than a recording. It is published so the shape can be argued
  with before it is built.

  It also predates **CONTRACT-SCHEMA v0** and still uses the older nouns — *ask* for
  Proposal, *receipt* for Version, *relationship* for Room. The
  [glossary](/glossary) maps them.
</Warning>

There is **one permission model** for humans and agents. Not two. Everything below is a way of naming
which person a call acts as, because every write on contracts.io is attributable to a person.

## Step zero: find out who you are

Every write names the person it acts for. Before you can write anything, you need that person's id, and
this is the call that hands it to you. Make it first, every time, with any credential.

```bash theme={null}
curl https://api.contracts.io/me \
  -H "Authorization: Bearer sk_live_..." \
  -H "Contracts-Acting-Person: per_9"
```

```json theme={null}
{
  "person_id": "per_9",
  "name": "Nora Whitfield",
  "email": "nora@acme.com",
  "org_id": "org_2",
  "org_name": "Acme",
  "parties": [
    { "contract": "con_1", "party": "pty_acme", "capacity": ["negotiator", "signer"] }
  ],
  "person_rung": 1,
  "org_ceiling": 2,
  "effective_rung": 1,
  "scopes": ["contracts:read", "contracts:write", "decisions:write", "send"],
  "pinned_version": "2026-08-24",
  "credential": "org_key",
  "is_guest": false
}
```

`GET /me` needs no scope. Any working credential may call it, which is the point: it is how you find
out what you are holding.

| What you are holding                      | What comes back                                                                                         |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| An org key with `Contracts-Acting-Person` | That person's seat, that person's capacities, the key's scopes.                                         |
| An org key with no acting person          | `person_id: null`, `parties: []`, and the key's scopes. You can read org-wide and write nothing.        |
| A per-person token                        | That person's seat. `credential` is `person_token`.                                                     |
| A guest link token                        | `is_guest: true`, `org_id: null`, and exactly one row in `parties`: the contract the link was made for. |

`parties` returns at most 50 rows with `has_more` and `next_cursor`. A person on four thousand contracts
is normal, and this call has to stay small. Use [`GET /contracts`](/api/contracts#list-contracts) when
you want the whole list.

`effective_rung` is already the answer. It folds the person's own rung, the org ceiling and any
per-contract arming into one number, so you never recompute it yourself. See
[the autonomy ladder](/api/mcp#the-autonomy-ladder).

## Base URL and version

```bash theme={null}
https://api.contracts.io
```

There is no `/v1/` in the path. Versions are **date-pinned per key** and overridable per call:

```bash theme={null}
Contracts-Version: 2026-08-24
```

A call with no `Contracts-Version` header uses the version pinned to the key.

## Org keys

Secret keys are scoped to one org and sent as a bearer token.

```bash theme={null}
curl https://api.contracts.io/contracts/con_1 \
  -H "Authorization: Bearer sk_live_..." \
  -H "Contracts-Version: 2026-08-24"
```

Test keys are `sk_test_…` and live keys are `sk_live_…`.

<Warning>
  **An org key must name the human it acts for on every write.**

  ```bash theme={null}
  Contracts-Acting-Person: per_9
  ```

  A key with no acting person can **read org-wide and write nothing**. This is not a convenience header;
  it is how a decision gets a `person_id`, how rate limits get charged to a seat, and how the audit
  trail stays truthful about who moved a word.
</Warning>

## Per-person tokens

For third-party apps, use OAuth. The resulting token *is* the seat, and three separate things travel
with it:

|                                                                    | Where it comes from                | Who can see it                                                                                  |
| ------------------------------------------------------------------ | ---------------------------------- | ----------------------------------------------------------------------------------------------- |
| **Capacity**: what the *contract* needs from you (negotiate, sign) | The contract's required-party list | Public. Both sides see it.                                                                      |
| **Authority**: what your *org* lets you do in its name             | Org membership                     | Private to your side. The counterparty must never learn that Acme made its junior get a review. |
| **Autonomy**: how far an agent goes alone                          | That person's rung                 | Private. See [MCP](/api/mcp#the-autonomy-ladder).                                               |

Capacity is not configurable by an admin. It is a fact about the contract, not a setting.

## Guests: reading and writing through a link

A guest is a person with no account and no org. Somebody sent them a contract and they can negotiate it
and sign it. That is not a lesser seat, it is the normal way the other side arrives.

**Reading uses the link.** The token rides as a query parameter, so the link somebody was emailed just
works:

```bash theme={null}
curl "https://api.contracts.io/contracts/con_1?t=gtk_8f3a..."
```

**Writing uses a header.** One form, on every write:

```bash theme={null}
curl -X POST https://api.contracts.io/contracts/con_1/decisions \
  -H "Authorization: Token gtk_8f3a..." \
  -H "If-Match: sha256:9f2c..." \
  -H "Idempotency-Key: 4f1e..." \
  -H "Content-Type: application/json" \
  -d '{ "block_id": "blk_9", "type": "counter",
        "text": "nine (9) months",
        "why": "We cannot commit past the end of our financial year." }'
```

Six things to know, and then a guest write is just a write.

1. **A write never carries the token in the URL.** Web addresses end up in server logs, in browser
   history and pasted into support tickets. A write sent with `?t=` and no header gets
   `401 unauthorized`.
2. **The token names one contract and one person**, and it supplies both. Do not send
   `Contracts-Acting-Person`. Sending one gets `400 invalid_request`.
3. **The token is the confirmation.** Do not send `confirmed_by` either. The link was mailed to that
   person, so somebody holding it is somebody who opened it. Sending `confirmed_by` gets
   `400 invalid_request` rather than being quietly believed.
4. **The link buys exactly what the contract gives that person.** If they were invited to negotiate, they
   can counter and reply. If they were invited to sign, they can sign. Nothing reaches any other contract,
   and `POST /ask` is never available to a guest.
5. **`If-Match` and `Idempotency-Key` work exactly as they do for a key.** Same headers, same statuses,
   same `409 words_changed` body when the words moved.
6. **Removing a guest from the contract kills their link.** An expired or revoked token returns
   `401 unauthorized`, never `403`, so somebody who was removed never learns whether the contract is still
   there.

<Note>
  **There is still no guest key issuance endpoint, ever.** A link token is not a key. It has no scopes,
  it cannot be listed, and it cannot be minted through the API. It is a contract and a person, and that is
  the whole grant. Guests are also never issued MCP connections.
</Note>

## MCP agents inherit the connecting person

An MCP agent connects **as the person who connected it** and holds that person's rung, capped by the org
ceiling, and capped additionally at *cannot send, cannot sign*. One rule, no second permission model for
agents.

<Warning>
  Do **not** authenticate an MCP server with an org key. An org key must name the human it acts for on
  every write, and MCP already has one: the person who connected it.
</Warning>

## Scopes

| Scope             | Grants                                                                                                                                  |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `contracts:read`  | Read contracts, contract state, letters, asks, facts, parties, relationships, receipts, signature state, playbooks, attention, threads. |
| `contracts:write` | Draft contracts, set facts, confirm facts and blocks, invite, cancel, amend.                                                            |
| `decisions:write` | Stage and withdraw decisions; set the reply note (`PATCH /contracts/{id}/reply`).                                                       |
| `send`            | `POST /contracts/{id}/send` and `POST /contracts/{id}/replies`.                                                                         |
| `sign`            | `POST /contracts/{id}/signatures` and signature withdrawal.                                                                             |
| `assets:write`    | Upload assets: files that are **not** becoming contracts.                                                                               |
| `imports:write`   | `POST /imports`: parse a document into a contract.                                                                                      |
| `ask:read`        | `POST /ask`: retrieval across the corpus.                                                                                               |
| `webhooks:manage` | Manage webhook endpoints.                                                                                                               |

<Note>
  **`imports:write` and `ask:read` are new** (reconciled 2026-08-24), and both are narrowing splits
  rather than additions. `assets:write` was carrying two different powers: attaching a rate card, and
  parsing a contract into a live contract. That is why importing got its own grant. `ask:read` came out of
  `contracts:read` because a corpus-wide question crosses every contract the seat can open, and a key granted
  to read one contract should not silently read across the org.

  Neither widens anything: retrieval and reads stay capped by the acting person's row-level access. The
  scope decides whether you may *ask*; the seat decides what can answer.
</Note>

<Note>
  `send` and `sign` are grantable. A key holding both **still** requires `confirmed_by` on the
  call. The scope decides whether you may *ask*; the confirmation decides whether it *happens*. See
  [the never-sends 409](/api/errors#confirmation-required).
</Note>

## Rate limits

Per org, with agent traffic counted against the **acting person**. A runaway harness throttles one
seat, not the company.

| Class           | Limit      |
| --------------- | ---------- |
| Reads           | 1000 / min |
| Writes          | 120 / min  |
| `send` + `sign` | 20 / min   |

A `429` carries `Retry-After`.

## Idempotency

`Idempotency-Key` is **required** on every POST that creates or moves a record: `send`, `replies`,
`signatures`, `POST /contracts`, `POST /imports` and `POST /assets`. Replays return the original response.

```bash theme={null}
-H "Idempotency-Key: 0f1c9a5e-4b2a-4b8e-9c1e-2d3f4a5b6c7d"
```

| Situation                                      | What you get                                    |
| ---------------------------------------------- | ----------------------------------------------- |
| Same key, same body                            | The original response, replayed.                |
| Same key, different body                       | `400 idempotency_error`.                        |
| Same key while the first call is still running | `409 idempotency_in_flight` with `Retry-After`. |
| Same key more than 24 hours later              | Treated as new. Keys are kept for 24 hours.     |

<Warning>
  **Generate one key per attempt, not one per candidate.** If you reuse a key across two genuinely
  different sends, the second one either replays the first response, which means people you never
  wrote to look like they were written to, or it fails with `idempotency_error`. Both are worse than
  a fresh UUID per try.
</Warning>

## Concurrency

Calls that can move words or commit a round take the contract's current words hash:

```bash theme={null}
-H "If-Match: sha256:9f2c..."
```

A stale hash returns `409 words_changed` with the new hash and the decisions that must be re-asked. It
is the signing compare-and-swap, generalized to every write. See [Errors](/api/errors#words-changed).

**You never need an extra read to find that hash.** Every call that changes anything returns
`words_hash` in its own response body. That value is what you send as `If-Match` on the next call.

## Every write, and the three headers it takes

`If-Match` is required only on calls that can move words or commit a round. It is accepted and ignored
on edits to your own private draft, and it does not apply where there is no contract hash yet.

| Write                                                 | `If-Match`                      | `Idempotency-Key` | `confirmed_by` |
| ----------------------------------------------------- | ------------------------------- | ----------------- | -------------- |
| `POST /contracts`                                     | not applicable                  | required          | no             |
| `POST /contracts/{id}/facts/{fact_id}`                | required                        | no                | no             |
| `POST /contracts/{id}/facts/{fact_id}/confirm`        | ignored                         | no                | no             |
| `POST /contracts/{id}/blocks/{block_id}/confirm`      | required                        | no                | no             |
| `POST /contracts/{id}/send`                           | required                        | required          | **required**   |
| `POST /contracts/{id}/decisions`                      | required                        | no                | no             |
| `POST /contracts/{id}/decisions/apply-playbook`       | required                        | no                | no             |
| `DELETE /contracts/{id}/decisions/{id}`               | ignored                         | no                | no             |
| `PATCH /contracts/{id}/reply`                         | ignored                         | no                | no             |
| `DELETE /contracts/{id}/reply`                        | ignored                         | no                | no             |
| `POST /contracts/{id}/replies`                        | required                        | required          | **required**   |
| `POST /contracts/{id}/signatures`                     | **in the body**, as `text_hash` | required          | **required**   |
| `POST /signatures/{id}/withdraw`                      | not applicable                  | required          | **required**   |
| `POST /asks/{id}/withdraw`                            | not applicable                  | no                | no             |
| `POST /contracts/{id}/invite`                         | not applicable                  | no                | no             |
| `DELETE /contracts/{id}/parties/{party_id}`           | not applicable                  | no                | no             |
| `POST /contracts/{id}/cancel`                         | not applicable                  | required          | no             |
| `POST /contracts/{id}/amend`                          | not applicable                  | required          | no             |
| `POST /imports`                                       | not applicable                  | required          | no             |
| `POST /assets`                                        | not applicable                  | required          | no             |
| `PUT` and `DELETE /people/{id}/mark`                  | not applicable                  | no                | no             |
| `POST /webhooks/endpoints` and the rest of that group | not applicable                  | no                | no             |
| `POST /ingress/{id}/accept` and `/reject`             | not applicable                  | required          | no             |

A word-moving call sent with no `If-Match` at all returns `428 if_match_required`. That is a different
failure from `409 words_changed`, which means you sent one and it was out of date.

<Note>
  **Signing is the one compare carried in the body.** `POST /contracts/{id}/signatures` takes `text_hash`
  as a field instead of `If-Match` as a header, because there the hash is not a cache check. It is the
  thing being signed. It goes onto the signature, onto the certificate, and it is what decides years
  later whether that signature is still live. A value that ends up on a legal record belongs in the
  body where a reader can see it. Sending `If-Match` to the sign endpoint returns
  `400 invalid_request`, so nobody sends both and wonders which one won.
</Note>

## What `confirmed_by` actually checks

`confirmed_by` names the person who pressed the button. It is checked on the server, not taken on
trust, on `send`, `replies`, `signatures` and signature withdrawal.

1. The person is on the contract's required-party list. If not: `403 not_on_required_party_list`.
2. The person holds the right capacity: negotiating for send and replies, signing for signatures and
   withdrawals. If not: `403 not_a_negotiator` or `403 not_a_signer`.
3. The person is in your own org. A key can never confirm as the other side.
4. The value is written onto the letter, the signature and the certificate, so "who pressed this"
   stays answerable from the record itself.

A key naming the same person it acts for is fine, and is what somebody running their own script looks
like. What is refused is a key confirming somebody who could not have done the act.
