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

# Signing

> A signature binds to a text hash, not to a contract. Compare-and-swap is the entire safety model.

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

<Warning>
  **Compare-and-swap is the API's whole signing safety model.** A signature is bound to a canonicalized
  text hash. If the words move, the consent does not follow them.
</Warning>

## Create a signature

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

<ParamField body="text_hash" type="string" required>
  The `words_hash` the signer actually read. This is the swap half of the compare-and-swap.
</ParamField>

<ParamField body="signer" type="object" required>
  `name` and `email` of the person binding themselves.
</ParamField>

<ParamField body="confirmed_by" type="string" required>
  The `per_…` of the human who pressed the button. No key signs on its own authority.
</ParamField>

<ParamField body="intent" type="string" required>
  The consent sentence shown to the signer, recorded word for word on the signature, on every receipt
  that names it, and on the certificate.
</ParamField>

<ParamField body="mark" type="object">
  How this person signs: `{ "id": "mrk_4" }`. Optional. A signature with no mark is completely valid,
  because the hash is the proof and the mark is only how it looks.
</ParamField>

```json Request theme={null}
{
  "text_hash": "sha256:9f2c...",
  "signer": { "name": "Sam Ford", "email": "sam@acme.com" },
  "confirmed_by": "per_12",
  "intent": "I agree to be bound by these words.",
  "mark": { "id": "mrk_4" }
}
```

```json 201 Created theme={null}
{
  "id": "sig_2", "state": "bound", "version": "ver_5", "executed": false,
  "signed_at": "2026-08-24T11:04:00Z", "words_hash": "sha256:9f2c..."
}
```

```json 409 Conflict theme={null}
{
  "error": {
    "type": "conflict_error",
    "code": "words_changed",
    "message": "The words moved. Read the contract again and sign the current text.",
    "param": "text_hash",
    "doc_url": "https://docs.contracts.io/errors#words-changed"
  },
  "current_hash": "sha256:be71...",
  "your_signature": { "id": "sig_1", "state": "void", "bound_hash": "sha256:9f2c..." }
}
```

`your_signature` is this signer's **previous** signature on the contract, with its real state, or `null`
if they had none. The press you just sent is rejected outright, so there is never a half-made signature
to clean up.

<Note>
  **The sign call is the one place the hash travels in the body.** Everywhere else it is an `If-Match`
  header. Here it is `text_hash`, a field, because it is not a cache check: it is the thing being
  signed. It goes onto the signature and onto the certificate, and it is what decides years from now
  whether this signature is still live. Sending `If-Match` to this endpoint returns
  `400 invalid_request`.
</Note>

### Who may call this

<Warning>
  **The signer opens the contract.** The person named in `confirmed_by` must be the person signing, they
  must hold signing capacity on this contract, and they must be in the calling key's own org. A sender can
  never sign for the person they sent to, whatever scopes they hold. That is not a policy setting, it
  is what the call checks.
</Warning>

There are exactly three legitimate callers:

1. **The signing person's own org key**, naming that person in both `Contracts-Acting-Person` and
   `confirmed_by`, driven by a surface where that person just pressed a button.
2. **That person's own OAuth token**, where the token is already the seat.
3. **A guest, through their signed link**, using `Authorization: Token <t>`. The token names one contract
   and one person, so it supplies both the acting person and the confirmation on its own. See
   [Guests](/api/authentication#guests-reading-and-writing-through-a-link).

Anything else returns `403 not_a_signer`. An agent or an MCP connection is never one of the three: see
[Signing over MCP](#signing-over-mcp).

### The mark

The mark is the ink. The hash is the proof. They do different jobs and only one of them decides
anything.

```bash theme={null}
GET    /people/{id}/mark      # 404 if this person has never made one
PUT    /people/{id}/mark      # multipart, or { "kind": "typed", "text": "Sam Ford" }
DELETE /people/{id}/mark      # forget my mark
```

```json 200 OK theme={null}
{
  "id": "mrk_4", "kind": "drawn", "checksum": "sha256:0c1e...",
  "image_url": "...", "authored_at": "2026-06-02T09:00:00Z"
}
```

* **A mark belongs to a person, never to a company.** One person, one mark at a time. `kind` is
  `drawn`, `typed` or `uploaded`.
* **The first mark is authored by a hand, never guessed.** A key or an MCP connection may point at an
  existing mark by id, and can never create one. Sending an inline mark body from a key returns
  `409 confirmation_required` with a link for the person to go and draw it.
* **Ink on something already signed never changes.** The signature pins the mark id **and its
  checksum** at the moment of pressing. Re-drawing your mark next year does not reach back into last
  year's contract.
* **`DELETE` is forget-my-mark.** It removes what we stored for that person and does not touch a mark
  already pinned to a signature, because that ink is part of a record somebody relied on.

## Signature states

| State       | Means                                                                                                                |
| ----------- | -------------------------------------------------------------------------------------------------------------------- |
| `held`      | Bound to a hash that is still current, but the contract has open asks. **A signature survives a counter as `held`.** |
| `bound`     | Live consent against the current words.                                                                              |
| `void`      | The words minted a new hash. The consent no longer describes anything.                                               |
| `withdrawn` | The signer pulled it back.                                                                                           |

<Note>
  A signature voids **only** when the words mint a new hash. Not when someone counters, not when a
  party is added, not when a fact is staged.
</Note>

**Four states, and every surface reports all four** (reconciled 2026-08-24): `held · bound · void ·
withdrawn`, in the app, over HTTP, and in the MCP tool list.

### How a signature moves between them

Only `void` and `withdrawn` are permanent. The other two go back and forth, and the round trip is worth
walking once because nothing else in the product behaves this way.

1. Everyone agrees. Sam signs. The signature is **`bound`**, live against the current words.
2. Somebody opens a new question on clause nine. **The contract goes back to `negotiating`**, and Sam's
   signature moves to **`held`**. It is still good. Nothing about the words has changed yet, so nothing
   about the consent has either. It is just waiting.
3. **If the question closes with the clause unchanged**, the hash never moved and the signature goes
   back to `bound`. Sam does nothing.
4. **If the question closes with new words**, the hash moves and the signature goes to **`void`**. Sam
   has to sign again, against the new text. This is what the re-sign row in the app is for.

<Warning>
  **`held` is not a weaker `bound`.** It means the words are unchanged and a question is open. If the
  words move, a held signature voids exactly like a bound one does. Anything that reports signature
  state has to sweep both.
</Warning>

`agreed` going back to `negotiating` is the backwards step that makes `held` reachable at all. See
[Concepts](/anatomy).

## Read the signatures

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

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

Who signed, which exact words they bound themselves to, when, under what sentence, and why a signature
is struck. The first returns every signature on a contract; the second takes a signature id when that is
all you are holding.

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "sig_2", "person": "per_12", "party": "pty_acme",
      "name": "Sam Ford", "email": "sam@acme.com",
      "text_hash": "sha256:9f2c...", "state": "bound",
      "intent": "I agree to be bound by these words.",
      "mark": { "id": "mrk_4", "kind": "drawn", "checksum": "sha256:0c1e..." },
      "signed_at": "2026-08-24T11:04:00Z",
      "voided_at": null, "void_reason": null,
      "withdrawn_at": null, "confirmed_by": "per_12"
    },
    {
      "id": "sig_1", "person": "per_12", "party": "pty_acme", "name": "Sam Ford",
      "text_hash": "sha256:1a4d...", "state": "void",
      "intent": "I agree to be bound by these words.",
      "signed_at": "2026-08-20T16:20:00Z",
      "voided_at": "2026-08-22T10:00:00Z", "void_reason": "words_changed"
    }
  ]
}
```

<Warning>
  **A dead signature stays in this list forever, holding the hash it really bound.** `sig_1` above is
  void and it is still here, still pointing at the exact words Sam agreed to on the twentieth. We never
  delete a signature and we never quietly repoint one at newer text. If somebody asks what Sam signed
  in August, this is the answer, and it does not change.
</Warning>

`void_reason` is `words_changed`, `party_removed` or `contract_cancelled`.

## Read the signature state

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

The cheap summary, when you want to know whether this can finish rather than read the whole record.

```json 200 OK theme={null}
{
  "words_hash": "sha256:9f2c...",
  "stale": false,
  "required_signers": [
    { "person": "per_12", "name": "Sam Ford", "party": "pty_acme",
      "capacity": ["negotiator", "signer"], "signature": "sig_2", "state": "bound" },
    { "person": "per_9", "name": "Nora Whitfield", "party": "pty_north",
      "capacity": ["negotiator", "signer"], "signature": null, "state": null }
  ],
  "held": ["sig_5"],
  "bound": ["sig_2"],
  "void": ["sig_1"],
  "withdrawn": []
}
```

<ResponseField name="required_signers" type="object[]">
  **People, not companies**, each with the signature they have made and its state. A row with
  `signature: null` is somebody you are still waiting on.
</ResponseField>

<ResponseField name="stale" type="boolean">
  `true` when the contract's current hash is not the hash these signatures bound to. It is the one field
  that tells you consent has come unstuck from the words without diffing anything yourself.
</ResponseField>

<Note>
  **"Who still has to sign" is one read.** It used to be uncomputable: the required list held company
  ids while the signature buckets held signature ids, and nothing joined them. Signing capacity belongs
  to a person and so does their mark, so companies were always the wrong unit. Two people at the same
  company can both be required, and now the response can say so.
</Note>

## There is no signature migration

<Warning>
  **`PATCH /signatures/{id}` does not exist.** There is no endpoint that migrates consent onto text
  nobody saw.
</Warning>

When a signature voids, the replacement is always a **fresh** `POST /contracts/{id}/signatures` against the
hash the signer read this time. That is the entire recovery path, and it is deliberately the only one.

Handling this correctly is what the `words.changed` webhook is for. It carries `signatures_voided[]`,
and it is the API's version of the app's *"the words changed, sign the updated contract"* row.

## Withdraw a signature

<ResponseField name="POST /signatures/{id}/withdraw" type="endpoint">
  Scopes: `sign` · `Idempotency-Key` required · `confirmed_by` required
</ResponseField>

```json Request theme={null}
{ "confirmed_by": "per_12" }
```

Moves the signature to `withdrawn` and fires `signature.withdrawn`.

<Warning>
  **Taking consent back is the same size of act as giving it.** So it takes the same confirmation.
  Omitting `confirmed_by` returns `409 confirmation_required`, exactly like a send.
</Warning>

* **Only the person who pressed it may withdraw it**, or their own org acting for them through
  `Contracts-Acting-Person`. The other side can never withdraw your signature, whatever scopes they
  hold. Anything else returns `403 not_a_signer`.
* **Never once the contract is executed.** That returns `409 contract_executed`. A finished contract is
  undone by amending it in the open, not by quietly removing a name from it.
* It takes no `If-Match`. The route carries no contract, and the signature already names the hash it
  bound.

## Execution

When the **last required signature binds**, the contract moves to `executed` and `contract.executed` fires
with a `version` and a `certificate_url`.

```json contract.executed theme={null}
{
  "contract": "con_1",
  "version": "ver_5",
  "certificate_url": "https://contracts.io/c/con_1/certificate.pdf",
  "origin": "api"
}
```

`executed` is terminal. There is no reopening it. To change an executed contract, call
[`POST /contracts/{id}/amend`](/api/contracts#amend), which mints a new contract with the same parties.

## The certificate

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

The certificate is **a place**, not a field that only exists inside one webhook you might have missed.
Fetch it whenever you want: while polling, after an outage, or for a contract that finished last year.

```json 200 OK theme={null}
{
  "url": "https://contracts.io/certificates/con_1.pdf",
  "version": "ver_5",
  "executed_at": "2026-08-24T11:04:00Z",
  "recipe": {
    "canonicalization": "contracts.io/canon/1",
    "hash_algorithm": "sha256",
    "source_events": ["evt_18", "evt_22"]
  }
}
```

* **`url` is signed and expires in 15 minutes.** Fetch this endpoint again to get a fresh one. It is
  not a public link, because a certificate names people and the words they agreed to.
* **`recipe` is what makes the record checkable by somebody who does not trust us**: the rule we used
  to normalise the text, the hash algorithm, and the log events the record was built from.
* A contract that is not executed has no certificate and returns `404 not_found`. So does a `filed` contract,
  because we never restate somebody else's execution as ours.

## Receipts

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

```json 200 OK theme={null}
{
  "data": [
    {
      "id": "ver_5",
      "hash": "sha256:9f2c...",
      "snapshot_at": "2026-08-24T14:02:00Z",
      "signed_by": [
        { "signature": "sig_2", "person": "per_12", "name": "Sam Ford",
          "intent": "I agree to be bound by these words.",
          "method": "drawn", "marked_at": "2026-08-24T11:04:00Z" }
      ]
    }
  ]
}
```

A `ver_` is a hash plus a snapshot at a signing moment. It exists so a bound signature can always point
at exactly the words it bound to.

<Note>
  **`signed_by` names people and carries their words.** It used to be a bare list of signature ids,
  which meant the one sentence that decides whether consent holds up, the sentence the signer actually
  agreed to, was demanded on the way in and then thrown away. Now the receipt carries who signed, the
  sentence they saw word for word, how they signed, and when.
</Note>

<Warning>
  **They are receipts, and the name is doing product work** (reconciled 2026-08-24). The read is
  `/receipts`, never `/versions`, and `GET /versions/{n}` is not a place you can visit.

  "Versions" invites a reader to browse a history and diff two entries. That is the document-versioning
  idiom this product refuses, because a contract's past is a set of moments consent attached to, not a
  filmstrip to scrub. A receipt answers one question: *which exact words did this signature bind to?*
</Warning>

## Signing over MCP

`prepare_signature` **is exposed** over MCP, and it is deliberately pointer-only: it reports that the
signature block is awake and who still has to sign, and it **never supplies a name, a mark, or an
intent.** It returns `confirmation_required` like every send-class tool, and the link it hands back is
where a human goes.

<Note>
  **What "pointer-only" means precisely.** It supplies no consent material: no signer name, no mark, no
  consent sentence, and no hash for a signature to bind to. It reports the contract's public
  `words_hash`, which is the same value any read returns and which the agent could already see. The
  distinction matters, because the promise is load-bearing and a version of it that is not exactly true
  is worse than a narrower one that is.
</Note>

<Note>
  Exposed after a ruling (reconciled 2026-08-24). The draft had it withheld on the reasoning that
  *consent is not a remote act*. That is right, and it is precisely what pointer-only honours. Supplying
  no consent material, it is strictly less capable than
  [`GET /contracts/{id}/signatures/state`](#read-the-signature-state), which was always available. So
  withholding it bought nothing and cost an agent the one sentence it most needs to say: *"the words are
  agreed, you and Alex still have to sign, here is the link."*
</Note>

<Warning>
  **Consent is not a remote act.** No agent, key, or integration supplies a signature's intent. The
  signer opens the contract.
</Warning>
