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

Create a signature

endpoint
required
Scopes: sign · Idempotency-Key required · confirmed_by required
string
required
The words_hash the signer actually read. This is the swap half of the compare-and-swap.
object
required
name and email of the person binding themselves.
string
required
The per_… of the human who pressed the button. No key signs on its own authority.
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.
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.
Request
201 Created
409 Conflict
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.
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.

Who may call this

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.
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.
Anything else returns 403 not_a_signer. An agent or an MCP connection is never one of the three: see 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.
200 OK
  • 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

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.
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.
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.
agreed going back to negotiating is the backwards step that makes held reachable at all. See Concepts.

Read the signatures

endpoint
Scopes: contracts:read
endpoint
Scopes: contracts:read
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.
200 OK
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.
void_reason is words_changed, party_removed or contract_cancelled.

Read the signature state

endpoint
Scopes: contracts:read
The cheap summary, when you want to know whether this can finish rather than read the whole record.
200 OK
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.
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.
“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.

There is no signature migration

PATCH /signatures/{id} does not exist. There is no endpoint that migrates consent onto text nobody saw.
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

endpoint
Scopes: sign · Idempotency-Key required · confirmed_by required
Request
Moves the signature to withdrawn and fires signature.withdrawn.
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.
  • 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.
contract.executed
executed is terminal. There is no reopening it. To change an executed contract, call POST /contracts/{id}/amend, which mints a new contract with the same parties.

The certificate

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

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

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.
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.
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, 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.”
Consent is not a remote act. No agent, key, or integration supplies a signature’s intent. The signer opens the contract.