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.
Eight steps, twelve calls, and a whole negotiation from nothing to signed. Both sides are here, because half of it happens in somebody else’s hands. The shape is always the same: find out who you are, draft, send, one reply cycle, sign.
Set these once. Every call below assumes them.

1. Find out who you are

Every write names the person it acts for, and you do not have that person’s id yet. This is the call that hands it to you. Make it first.
200 OK
A test key with no Contracts-Acting-Person header comes back with person_id: null. That is a key that can read across your org and write nothing. Bind it to a person in Settings, Developers, or send the header on every call, as we do below.

2. Draft a contract

Give a prompt or a template_id, not both.
201 Created
Your prompt named a recipient, so you got proposed_recipients. Never a sent contract. That is the API’s version of a Send sheet, addressed, one button unpressed. Nothing has left your side.
questions[] is capped at three and only ever contains genuine forks: things the draft cannot resolve without you, not a form to fill in. Keep words_hash. Every call that changes anything returns it, and it is what you send as If-Match on the next one. You never need an extra GET to find it.

3. Fill a fact, before the first send

Before the first send you hold the words alone, so a fact change applies directly. fct_term came back in the response above.
200 OK
The hash moved. Carry the new one into the next call. After the first send this same request returns "applied": false and stages a proposal instead, and the hash comes back unchanged, because a proposal moves no words.

4. Send, and the room is born

Send is confirmation-class. confirmed_by is the per_… of the human who pressed the button, and the server checks that they are really on this contract and really allowed to do this.
200 OK
relationship_created: true is the only trace of a room being born. There was no POST /rooms, no routing order, no recipient roles configured before words moved, and no “send for signature” mode. Capacity did that work.
Drop confirmed_by and you get 409 confirmation_required. Do not retry it. Assemble the letter, put it in front of a person, and call again once they have pressed the button.

5. The other side counters

Sam has no account with us. He got an email with a link, and he can negotiate and sign through it. This is the normal way the other side arrives, so it is worth doing once here rather than discovering it in production. Reading uses the link, with the token as a query parameter:
Writing uses a header. Sam wants nine months instead of twelve, and nobody has raised that question yet, so he names the clause rather than a question:
201 Created
Then he commits the round:
A guest call sends no Contracts-Acting-Person and no confirmed_by. The link token names one contract and one person, so it already supplies both. Sending either one returns 400 invalid_request rather than being quietly believed. Everything else works exactly as it does for a key: same If-Match, same Idempotency-Key, same conflict responses. See Guests.
Use block_id when nobody has raised the question yet, and ask_id once somebody has. Send one or the other, never both.

6. Read the inbound round and answer it

letter.received fires on your side. Fetch the round.
200 OK
The letter came from: "pty_acme", so pty_acme is in agreed. A party agrees by proposing. Unanimity is only countable if the proposer is counted, so never read agreed as “everyone but them”. Nine months is fine. Stage an accept. Staging writes to your unsent reply, and nothing leaves your side. The other party cannot see it, count it, or be told about it.
201 Created
Inspect the round before committing it, and back out anything you change your mind about:
GET /contracts/{id}/reply carries landed_if_sent, which is the server working out what would actually change if you sent this. Never compute that yourself.
why is required on a counter and on a decline. A counter without a reason is a fight.

7. Commit the reply

200 OK
Three fields carry the whole result:
  • landed are the clauses that reached unanimity on this send. This is the only way words ever change. blk_9 now reads “nine (9) months”, and the hash moved because of it.
  • asks_opened are questions this round put on the table. Empty here, because you accepted rather than countered. A counter always returns landed: []: it opens a question and waits, it does not move words.
  • re_asked are decisions killed because a letter from the other side moved that clause while your round was staged. Re-decide those, and only those, then commit again.
state is now agreed. Everyone has said yes to every clause, contract.agreed fires, and the signing block wakes up. If the hash you sent was stale, you get 409 words_changed instead, with the current hash and a non-empty re_asked. Refetch, re-decide, retry.
409 words_changed

8. Sign

Wait for agreed before you try this. Signing an unfinished contract is refused. Step 7 returned "state": "agreed", and contract.agreed fired on both sides. If you are polling instead of listening, GET /contracts/con_1/state is the check.
A signature binds to an exact text hash, never to “the contract”. Each person signs for themselves. You sign as your own signer, using your own key:
201 Created
Sam signs through his own link, from his own browser:
You cannot sign for the person you sent to. The person in confirmed_by must be the person signing, must hold signing capacity, and must be in your own org. Anything else returns 403 not_a_signer. Consent is not something one side supplies on behalf of the other, and the API enforces that rather than trusting it.
If the words moved while a signer was reading, the compare-and-swap catches it:
409 words_changed
Never migrate a signature onto new text. A signature survives an open question as held. It voids only when the words mint a new hash, and the replacement is always a fresh POST /contracts/{id}/signatures against the hash that signer actually saw.
When the last required signature binds, contract.executed fires with a version and a certificate_url, and you can fetch the record any time from GET /contracts/{id}/certificate. executed is terminal. To change it, POST /contracts/{id}/amend.

The hash flow, in one picture

Staging never moves the hash. Only a landed clause does. That is why you can stage a whole round without racing the other side, and why the race, when it happens, surfaces exactly once: at the commit.

Next

Every write and its headers

Which calls take If-Match, Idempotency-Key and confirmed_by.

Errors

Every code, its status, and how to recover.

Endpoint reference

Every public call, grouped by resource.

Webhooks

Eighteen event types, HMAC signing, and how to catch up after an outage.