> ## 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.

# Contracts

> List templates; draft, read, and send a contract; set and confirm facts; invite and remove parties; cancel and amend.

<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>

The contract is the atom. There is no create-a-container call above it.

## Create a contract

<ResponseField name="POST /contracts" type="endpoint" required>
  Scopes: `contracts:write` · `Idempotency-Key` required
</ResponseField>

Supply a `prompt` **or** a `template_id`, not both. Both together returns `400 invalid_request` with
`param: "prompt"`.

<ParamField body="prompt" type="string">
  A sentence describing the instrument. Mutually exclusive with `template_id`.
</ParamField>

<ParamField body="template_id" type="string">
  A `tpl_…` from your org library. Mutually exclusive with `prompt`.
</ParamField>

<ParamField body="facts" type="object">
  Fact name to value. Filled directly, because nobody else holds the words yet.
</ParamField>

```json Request theme={null}
{
  "prompt": "6-month contract PM at £600/day, send to sam@acme.com",
  "facts": { "governing_law": "England & Wales", "term_months": 6 }
}
```

```json 201 Created theme={null}
{
  "id": "con_1",
  "state": "draft",
  "origin": "api",
  "url": "https://contracts.io/c/con_1",
  "words_hash": "sha256:1a4d...",
  "head_sequence": 1,
  "blocks": [ { "id": "blk_1", "number": 1, "kind": "clause", "text": "..." } ],
  "facts": [
    { "id": "fct_law", "name": "governing_law", "value": "England & Wales",
      "inferred": false, "occurrences": ["blk_12"] }
  ],
  "questions": [ { "id": "q_1", "asks": "Who are the parties?" } ],
  "proposed_recipients": [
    { "email": "sam@acme.com", "capacity": ["negotiator", "signer"] }
  ]
}
```

<Warning>
  A prompt that names a recipient returns `proposed_recipients`. **Never a sent contract.** Nothing leaves
  your side without a separate, confirmed `POST /contracts/{id}/send`.
</Warning>

`questions[]` is capped at three and contains only genuine forks.

<Note>
  **`words_hash` comes back from every call that changes anything.** Create, set a fact, send, stage a
  decision, commit a reply, confirm a block: all of them return the contract's current hash in the response
  body. That value is what you send as `If-Match` on your next call. You never need an extra `GET` to
  find it, and doing one anyway opens the exact race the header exists to close.
</Note>

`blocks[].kind` is one of `clause`, `signature_block`, `heading`, `exhibit`. It is what makes "two
signature blocks" a number you can count rather than a claim.

## List templates

<ResponseField name="GET /templates" type="endpoint">
  Scopes: `contracts:read` · org-scoped
</ResponseField>

Your org's instrument library, and the blanks each instrument leaves open. This is where the `tpl_…` you
pass to [Create a contract](#create-a-contract) comes from, so you never have to hardcode an id or ask us
for the list.

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "tpl_nda_mutual",
      "name": "Mutual NDA",
      "description": "Two-way confidentiality, three years, mutual carve-outs.",
      "blanks": [
        { "key": "counterparty_name", "label": "Who you are signing with",
          "kind": "party", "required": true },
        { "key": "term_years", "label": "How long this runs",
          "kind": "term", "required": true },
        { "key": "governing_law", "label": "Whose law decides",
          "kind": "jurisdiction", "required": false }
      ]
    }
  ],
  "has_more": false
}
```

<ResponseField name="blanks[].key" type="string">
  Keyed the way `POST /contracts` takes them. Every `required: true` key belongs in the `facts` object on
  the create call.
</ResponseField>

<ResponseField name="blanks[].kind" type="string">
  A fact kind, from the closed list: `party`, `jurisdiction`, `term`, `money`, `date`, `notice`. A blank is
  always something the words will carry, never a custom field.
</ResponseField>

Leave a required blank out and the contract comes back carrying a `question` for it rather than a guess.
That is the same fork the prompt path already has, so both ways of creating a contract fail in the same
readable direction.

<Note>
  **Read-only.** There is no `POST`, `PATCH`, or `DELETE /templates`. Authoring an instrument is org
  settings, the same line `GET /playbooks` draws for a playbook floor: a template you could write over the
  API is clause text written without an ask.
</Note>

## Retrieve a contract

<ResponseField name="GET /contracts/{id}" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Returns the sheet as **your seat** sees it: `state`, `origin`, `blocks[]` with current words, `facts[]`
with spans, `parties[]` with public capacity, `words_hash`, `head_sequence`, `ready`, and `you`.

Clause text is the unanimity fold, never a stored string. A guest fetches the same contract through their
signed link:

```bash theme={null}
curl "$API/contracts/con_1?t=gtk_8f3a..."
```

### `you`: which chair you are sitting in

Knowing your own person id is not the same as knowing what this particular contract lets you do. Both this
read and `GET /contracts/{id}/state` answer that in one object.

```json theme={null}
"you": {
  "person": "per_12",
  "party": "pty_acme",
  "capacity": ["negotiator", "signer"],
  "is_guest": true
}
```

`you` is never null on a `200`, because somebody who is not a party never gets a `200` in the first
place. A guest reading through their link gets `is_guest: true` and the one party the link names.

### Contract states

| State         | Means                                                                                                                                                            |
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `draft`       | Nobody has seen it but your side.                                                                                                                                |
| `sent`        | It went out. Nobody has replied yet.                                                                                                                             |
| `negotiating` | At least one open question is on the table.                                                                                                                      |
| `agreed`      | Everyone has said yes to every clause. Signing can start.                                                                                                        |
| `executed`    | Everyone who had to sign has signed. Done.                                                                                                                       |
| `cancelled`   | Somebody killed the deal.                                                                                                                                        |
| `filed`       | A record of an agreement signed somewhere else. Readable and searchable, never negotiable, never signable. See [Imports](/api/imports#already-signed-documents). |

`executed` and `filed` are both terminal, and `cancelled` is terminal in practice. `agreed` can go back
to `negotiating` if somebody opens a new question, which is what puts a signature into `held`.

## Read the whole state of a contract

<ResponseField name="GET /contracts/{id}/state" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

One call for everything happening on a contract, when `GET /contracts/{id}` (the sheet) is not enough.

```json 200 OK theme={null}
{
  "contract": "con_1",
  "state": "negotiating",
  "words_hash": "sha256:9f2c...",
  "head_sequence": 22,
  "you": { "person": "per_9", "party": "pty_north",
           "capacity": ["negotiator", "signer"], "is_guest": false },
  "letters": [
    { "id": "let_2", "kind": "sent", "sequence": 4, "from": "pty_acme",
      "sent_at": "2026-08-24T09:41:00Z" }
  ],
  "asks_open": [ { "id": "ask_3", "block": "blk_9",
                   "tally": { "agreed": ["pty_acme"], "awaiting": ["pty_north"] } } ],
  "asks_landed": ["ask_1"],
  "asks_voided": ["ask_2"],
  "owed": [ { "party": "pty_north", "since": "2026-08-24T09:41:00Z" } ],
  "my_reply": { "decisions": 1, "note": "", "sent": false },
  "signatures": {
    "required_signers": [
      { "person": "per_12", "name": "Sam Ford", "party": "pty_acme",
        "capacity": ["negotiator", "signer"], "signature": null, "state": null },
      { "person": "per_9", "name": "Nora Whitfield", "party": "pty_north",
        "capacity": ["negotiator", "signer"], "signature": null, "state": null }
    ],
    "held": [], "bound": [], "void": [], "withdrawn": [], "stale": false
  }
}
```

<Note>
  **Who still has to sign is one read, not a join.** `required_signers[]` lists **people**, not
  companies, with the signature each one has made and what state it is in. Signing capacity belongs to
  a person and so does their mark, so a list of company ids could never say that two people at the same
  company both have to sign, and could never answer the only question this read exists for: can this
  finish, and who are we waiting on?
</Note>

<Note>
  **A blessed composite read** (reconciled 2026-08-24). This is the same read the MCP server's
  `get_room_state` performs, and it is public rather than an agent-only convenience for a plain reason:
  an integrator reassembling a contract's status from six round-trips will get it wrong in a different way
  each time, and *"who owes whom, and since when"* is the question every inbox actually asks.

  It composes reads your seat already holds and can show **nothing** the individual GETs would not.
  `my_reply` is your own unsent round; the counterparty's is not here, not counted, and not hinted at.
</Note>

## List contracts

<ResponseField name="GET /contracts" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Cursor-paginated with `starting_after` and `limit`, newest first.

<ParamField query="origin" type="string">
  Comma-separated creation-source filter: `manual`, `template`, `prompt`, `api`, `mcp`, `import`,
  `email`.
</ParamField>

<ParamField query="state" type="string">
  Comma-separated: `draft`, `sent`, `negotiating`, `agreed`, `executed`, `cancelled`, `filed`.
</ParamField>

<ParamField query="relationship" type="string">
  A `rel_…`. Everything with one counterparty.
</ParamField>

<ParamField query="updated_since" type="string">
  An ISO timestamp. Everything that moved since then. This is the coarse catch-up filter for a poller,
  or for an integration coming back after an outage.
</ParamField>

```bash theme={null}
curl "$API/contracts?updated_since=2026-08-24T07:00:00Z&state=negotiating&limit=25"
```

```json 200 OK theme={null}
{
  "data": [
    { "id": "con_9", "state": "negotiating", "origin": "import",
      "title": "Northwind MSA", "relationship": "rel_4",
      "words_hash": "sha256:be71...", "head_sequence": 22,
      "updated_at": "2026-08-24T09:12:00Z" }
  ],
  "has_more": false,
  "next_cursor": null
}
```

`origin` is provenance metadata only. Nothing in permissions, ready-checks or signing may read it.

## Walk a contract's events

<ResponseField name="GET /contracts/{id}/events" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Everything that ever happened to one contract, in order, as a cursor you can resume from. This is the
catch-up door: if your webhook endpoint was down during a deploy, this is how you find out what you
missed without re-reading every contract you have.

<ParamField query="starting_after" type="integer">
  A `sequence`. Returns everything after it, **oldest first**, which is the only order a cursor you
  resume from can walk.
</ParamField>

```bash theme={null}
curl "$API/contracts/con_1/events?starting_after=18&limit=50"
```

```json 200 OK theme={null}
{
  "data": [
    { "id": "evt_19", "sequence": 19, "type": "letter.received",
      "created": "2026-08-24T09:12:00Z",
      "data": {
        "contract": "con_1", "letter": "let_2", "asks": [],
        "relationship": "rel_4", "origin": "api",
        "words_hash": "sha256:be71...", "state": "negotiating"
      } }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Events here are rendered on **your** seat, exactly as a webhook delivery would be, so this log can never
show you more than the equivalent `GET` would. `head_sequence` on the contract read tells you where the
log currently ends.

The `type` is the same string a delivery would have carried, rendered on your side, so one handler
serves both surfaces. The log's own atom for a committed round is `letter.sent`, and it never surfaces
under that name: you get `letter.received` or `reply.sent` depending on which side you are. There is no
nineteenth type here. The one difference from a delivery is that a row carries no `endpoint`, because it
was read rather than sent.

## List letters

<ResponseField name="GET /contracts/{id}/letters" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Every committed round on the contract, oldest first, cursor-paginated. Each row carries `sequence` and
`kind`, so a poller has a real ordering to walk instead of guessing from timestamps.

```json 200 OK theme={null}
{
  "data": [
    { "id": "let_0", "kind": "imported", "sequence": 1,
      "authored_at": "2026-07-24T11:02:00Z", "author": "priya@fernbrook.com",
      "note": "Attaching our standard form.", "asks": [], "decisions": [] },
    { "id": "let_1", "kind": "sent", "sequence": 3, "from": "pty_north",
      "sent_at": "2026-08-24T08:00:00Z", "note": "First draft.", "asks": [] }
  ],
  "has_more": false,
  "next_cursor": null
}
```

<Note>
  **`kind: "imported"` letters weigh nothing.** When a room is made from a forwarded mail thread, the
  messages that came before it are seeded onto the contract so the history is there to read. They always
  carry `asks: []` and `decisions: []`, they are left out of every tally and every "who owes a reply"
  calculation, and **you can never create one**. There is no endpoint for it. A letter is a round
  somebody committed through this product, and back-dating that would break the one promise the
  negotiation record exists to make.
</Note>

## Set a fact

<ResponseField name="POST /contracts/{id}/facts/{fact_id}" type="endpoint">
  Scopes: `contracts:write` before first Send · `decisions:write` after · `If-Match` required
</ResponseField>

```json Request theme={null}
{ "value": "9 months" }
```

Behaviour depends on which side of the first Send you are on:

```json 200 before first Send theme={null}
{ "applied": true, "blocks_touched": ["blk_3", "blk_9"], "words_hash": "sha256:7b02..." }
```

```json 200 after first Send theme={null}
{
  "applied": false,
  "decision_id": "dec_7",
  "grouped": true,
  "blocks_touched": ["blk_3", "blk_9", "blk_14"],
  "words_hash": "sha256:1a4d..."
}
```

After the first send the hash comes back **unchanged**, because staging a proposal moves no words. It
moves when the other side agrees, and not before.

One fact, one grouped ask, one decision for the counterparty. Never a metadata write the words do not
carry. **A fact with no span cannot be created**: a value that appears nowhere in the text returns
`400 fact_has_no_span`.

## Confirm an inferred fact

<ResponseField name="POST /contracts/{id}/facts/{fact_id}/confirm" type="endpoint">
  Scopes: `contracts:read` · no `If-Match`
</ResponseField>

Clears `inferred: true` on a fact a parse extracted. This is the API face of the facts sheet as the
parse-verification surface: eight facts checked instead of fourteen pages re-read.

```json 200 OK theme={null}
{
  "id": "fct_law", "value": "England & Wales", "inferred": false,
  "occurrences": ["blk_12"], "confirmed_by": "per_9",
  "words_hash": "sha256:be71..."
}
```

It is `contracts:read` because agreeing with what the parse already said writes no words. It works before
or after the first send, and it takes no `If-Match` for the same reason. **Changing a fact is the other
call**, `POST /contracts/{id}/facts/{fact_id}`, which moves words before the first send and stages a
proposal after it.

Sending a contract with unconfirmed facts returns a `warning`, not a refusal.

## Send

<ResponseField name="POST /contracts/{id}/send" type="endpoint" required>
  Scopes: `send` · `Idempotency-Key` required · `If-Match` required · `confirmed_by` required
</ResponseField>

<ParamField body="recipients" type="array" required>
  Each entry: `email`, `name`, and a `capacity` set drawn from `negotiator` and `signer`.
</ParamField>

<ParamField body="note" type="string">
  The covering sentence on this round.
</ParamField>

<ParamField body="confirmed_by" type="string" required>
  The `per_…` of the human who pressed the button. Omitting it returns `409 confirmation_required`.
</ParamField>

```json Request theme={null}
{
  "recipients": [
    { "email": "sam@acme.com", "name": "Sam Ford", "capacity": ["negotiator", "signer"] }
  ],
  "note": "First draft. Shout if the cap is wrong.",
  "confirmed_by": "per_9"
}
```

```json 200 OK theme={null}
{
  "state": "sent", "letter": "let_1",
  "relationship": "rel_4", "relationship_created": true,
  "words_hash": "sha256:7b02..."
}
```

`relationship_created: true` is the only trace of a room being born. There is no routing order and no
"send for signature" mode. Capacity does that work.

<Note>
  **`confirmed_by` is checked, not taken on trust.** The person you name must be on this contract's
  required-party list, must hold negotiating capacity, and must be in your own org. A key can never
  confirm as the other side. The value is written onto the letter and onto the certificate, so who
  pressed the button stays answerable from the record itself. See
  [Authentication](/api/authentication#what-confirmed-by-actually-checks).
</Note>

## Invite a person

<ResponseField name="POST /contracts/{id}/invite" type="endpoint">
  Scopes: `contracts:write`
</ResponseField>

Adds a person to the required-party list (**your side or theirs**) with a capacity set. Guests are
people without orgs; they receive a signed link, not a key.

<ParamField body="email" type="string" required>
  Who to invite. If we already hold a person at this address you get their existing `per_…` back.
</ParamField>

<ParamField body="name" type="string" required>
  How they are named on the contract and on the certificate.
</ParamField>

<ParamField body="capacity" type="array" required>
  A non-empty set drawn from `negotiator` and `signer`. An empty set returns `400 invalid_request` with
  `param: "capacity"`.
</ParamField>

```json Request theme={null}
{
  "email": "priya.raman@northwindlabs.com",
  "name": "Priya Raman",
  "capacity": ["negotiator", "signer"]
}
```

```json 201 Created theme={null}
{
  "party": "pty_4",
  "person": "per_31",
  "invited": true,
  "link_sent": true
}
```

`invited` and `link_sent` are two fields rather than one because they disagree in the ordinary case:
inviting somebody already on the list returns `200` with both `false` and sends no second email. That is
what makes the call safe to retry without an `Idempotency-Key`. No `If-Match` either, for a cleaner
reason: a roster change moves no words, so there is no hash for yours to be stale against.

Fires `party.changed` with `change: "invited"`.

<Note>
  **A guest can invite, on their own side only**, at a capacity no higher than the one they hold. A guest
  with `negotiator` cannot mint a `signer`.
</Note>

## Remove a party

<ResponseField name="DELETE /contracts/{id}/parties/{party_id}" type="endpoint">
  Scopes: `contracts:write`
</ResponseField>

Their **sent decisions are preserved** and the tally recomputes. A roster change never rewrites history.

```json 200 OK theme={null}
{
  "removed": "pty_4",
  "decisions_preserved": 3,
  "required_signers": ["pty_1", "pty_2"],
  "tally_recomputed": true,
  "token_revoked": true
}
```

This returns a body rather than a `204`, on purpose. Removing a party recomputes every open ask's tally
and can carry the contract into or out of unanimity in the same call, and a caller handed an empty
response has to re-read the whole contract to find out whether it just moved.

<Warning>
  **`token_revoked` is not a courtesy field.** The signed link that person was mailed stops working the
  moment the row goes. A later call with it returns `401 unauthorized`, never `403`, so a removed guest
  never learns whether the contract still exists.
</Warning>

## Cancel

<ResponseField name="POST /contracts/{id}/cancel" type="endpoint">
  Scopes: `contracts:write` · `Idempotency-Key` required
</ResponseField>

<ParamField body="reason" type="string" required>
  A human sentence, not an enum. It travels as the note on the final letter.
</ParamField>

```json Request theme={null}
{ "reason": "The role was filled internally." }
```

```json 200 OK theme={null}
{
  "state": "cancelled",
  "cancelled_at": "2026-08-25T09:12:00Z",
  "cancelled_by": "per_9",
  "letter": "let_7"
}
```

`cancelled_by` is the acting person from `Contracts-Acting-Person`. Cancel takes no `confirmed_by`, and
an ending still has to be answerable from the record itself. `letter` is the final letter the ending
sends: a note and no asks, which is why the reason travels as a sentence somebody wrote rather than as a
code somebody picked.

Fires `contract.cancelled`, so an integration syncing state finds out instead of holding a dead deal at
`negotiating` forever. The contract stays readable forever. `cancelled` is a state, never a deletion.

## Amend

<ResponseField name="POST /contracts/{id}/amend" type="endpoint">
  Scopes: `contracts:write` · `Idempotency-Key` required
</ResponseField>

`executed` is terminal, and so is `filed`. This mints a **new** contract with the same parties, linked by
`amends`. The new contract's `origin` records the hand that made the amendment, not its parent's. Amending
a `filed` contract is how a renewal of something signed years ago on paper begins.

<ParamField body="note" type="string">
  Optional. Becomes the covering sentence on the new contract's first Send.
</ParamField>

```json Request theme={null}
{ "note": "Renewing for a further year on the same terms." }
```

```json 201 Created theme={null}
{
  "id": "con_14",
  "state": "draft",
  "amends": "con_9",
  "origin": "api",
  "parties": ["pty_1", "pty_2"],
  "words_hash": "sha256:0b8e...",
  "url": "https://contracts.io/c/con_14"
}
```

The new contract arrives at `draft`, **not** `sent`. Amending is an origination, and nothing leaves your
side until a separate, confirmed `POST /contracts/{id}/send`. `amends` is the only link between the two;
the parent stays `executed` or `filed` forever.

## Files on a contract

<ResponseField name="GET /contracts/{id}/assets" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Every file bound to this contract: the source PDF a parse came from, the mail thread it arrived in, an
exhibit, a rate card.

```json 200 OK theme={null}
{
  "data": [
    { "id": "ast_1", "role": "provenance", "filename": "northwind-msa.pdf",
      "content_type": "application/pdf", "pages": 14, "bytes": 481203,
      "checksum": "sha256:1c0d..." }
  ]
}
```

`role` is one of `provenance`, `exhibit`, `rate_card`, `attachment`. A file uploaded through
[`POST /assets`](/api/imports#upload-a-file) must name the contract or the relationship it
belongs to, and a file that produced a contract through an import is bound to it automatically.

## Read a clause thread

<ResponseField name="GET /contracts/{id}/blocks/{block_id}/thread" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

Returns `moves[]` and `talk[]` for one clause. **Read-only in v1.**

```json 200 OK theme={null}
{
  "thread": "thr_9",
  "block": "blk_9",
  "moves": [
    { "ask": "ask_12", "at": "2026-08-21T14:32:00Z", "person": "per_12",
      "from": "thirty (30) days", "to": "twenty-one (21) days",
      "letter": "let_3", "standing": "open" }
  ],
  "talk": [
    { "at": "2026-08-21T14:33:00Z", "person": "per_12",
      "text": "Twenty-one is what we run everywhere else." }
  ],
  "has_more": false
}
```

`moves[]` and `talk[]` are kept apart because one is the record and the other is the conversation. A
reader who cannot tell them apart will quote a sentence somebody typed as though it were a term. Every
entry in `moves[]` names the `letter` it left in, so a move is always traceable to a committed round.

There is no `POST .../thread`. A sentence meant for the counterparty travels as the `why` on a decision or
the `note` on a round, where it is attached to something.

Internal notes are a separate party-private layer and are **not in v1**. Exposing them would let an
integration leak your side's notes into a letter.

## Read the relationship

<ResponseField name="GET /relationships/{id}" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

```json 200 OK theme={null}
{
  "id": "rel_4",
  "contracts": [ { "id": "con_1", "state": "negotiating", "origin": "api" } ],
  "people": [ { "id": "per_12", "name": "Sam Ford", "email": "sam@acme.com" } ],
  "assets": [ { "id": "ast_1", "filename": "northwind-msa.pdf" } ]
}
```

<Warning>
  **Derived, read-only, never POSTed.** No `POST /relationships`, no `name` you set, no membership call.
  The API has had the relationship since Send.
</Warning>
