Create a contract
endpoint
required
Scopes:
contracts:write · Idempotency-Key requiredprompt 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
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-scopedtpl_… 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.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:readstate, 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:readGET /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:readstarting_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:readinteger
A
sequence. Returns everything after it, oldest first, which is the only order a cursor you
resume from can walk.200 OK
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:readsequence 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 requiredRequest
200 before first Send
200 after first Send
400 fact_has_no_span.
Confirm an inferred fact
endpoint
Scopes:
contracts:read · no If-Matchinferred: 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
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 requiredarray
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:writestring
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:write200 OK
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.
Cancel
endpoint
Scopes:
contracts:write · Idempotency-Key requiredstring
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 requiredexecuted 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
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:read200 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:readmoves[] 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:read200 OK