Skip to main content
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 maps them.
The contract is the atom. There is no create-a-container call above it.

Create a contract

endpoint
required
Scopes: contracts:write · Idempotency-Key required
Supply a prompt or a template_id, not both. Both together returns 400 invalid_request with param: "prompt".
string
A sentence describing the instrument. Mutually exclusive with template_id.
string
A tpl_… from your org library. Mutually exclusive with prompt.
object
Fact name to value. Filled directly, because nobody else holds the words yet.
Request
201 Created
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.
questions[] is capped at three and contains only genuine forks.
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.
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

endpoint
Scopes: contracts:read · org-scoped
Your org’s instrument library, and the blanks each instrument leaves open. This is where the tpl_… you pass to Create a contract comes from, so you never have to hardcode an id or ask us for the list.
200 OK
string
Keyed the way POST /contracts takes them. Every required: true key belongs in the facts object on the create call.
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.
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.
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.

Retrieve a contract

endpoint
Scopes: contracts:read
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:

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

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

endpoint
Scopes: contracts:read
One call for everything happening on a contract, when GET /contracts/{id} (the sheet) is not enough.
200 OK
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?
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.

List contracts

endpoint
Scopes: contracts:read
Cursor-paginated with starting_after and limit, newest first.
string
Comma-separated creation-source filter: manual, template, prompt, api, mcp, import, email.
string
Comma-separated: draft, sent, negotiating, agreed, executed, cancelled, filed.
string
A rel_…. Everything with one counterparty.
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.
200 OK
origin is provenance metadata only. Nothing in permissions, ready-checks or signing may read it.

Walk a contract’s events

endpoint
Scopes: contracts:read
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.
integer
A sequence. Returns everything after it, oldest first, which is the only order a cursor you resume from can walk.
200 OK
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

endpoint
Scopes: contracts:read
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.
200 OK
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.

Set a fact

endpoint
Scopes: contracts:write before first Send · decisions:write after · If-Match required
Request
Behaviour depends on which side of the first Send you are on:
200 before first Send
200 after first Send
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

endpoint
Scopes: contracts:read · no If-Match
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.
200 OK
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

endpoint
required
Scopes: send · Idempotency-Key required · If-Match required · confirmed_by required
array
required
Each entry: email, name, and a capacity set drawn from negotiator and signer.
string
The covering sentence on this round.
string
required
The per_… of the human who pressed the button. Omitting it returns 409 confirmation_required.
Request
200 OK
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.
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.

Invite a person

endpoint
Scopes: contracts:write
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.
string
required
Who to invite. If we already hold a person at this address you get their existing per_… back.
string
required
How they are named on the contract and on the certificate.
array
required
A non-empty set drawn from negotiator and signer. An empty set returns 400 invalid_request with param: "capacity".
Request
201 Created
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".
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.

Remove a party

endpoint
Scopes: contracts:write
Their sent decisions are preserved and the tally recomputes. A roster change never rewrites history.
200 OK
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.
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.

Cancel

endpoint
Scopes: contracts:write · Idempotency-Key required
string
required
A human sentence, not an enum. It travels as the note on the final letter.
Request
200 OK
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

endpoint
Scopes: contracts:write · Idempotency-Key required
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.
string
Optional. Becomes the covering sentence on the new contract’s first Send.
Request
201 Created
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

endpoint
Scopes: contracts:read
Every file bound to this contract: the source PDF a parse came from, the mail thread it arrived in, an exhibit, a rate card.
200 OK
role is one of provenance, exhibit, rate_card, attachment. A file uploaded through POST /assets 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

endpoint
Scopes: contracts:read
Returns moves[] and talk[] for one clause. Read-only in v1.
200 OK
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

endpoint
Scopes: contracts:read
200 OK
Derived, read-only, never POSTed. No POST /relationships, no name you set, no membership call. The API has had the relationship since Send.