Create a signature
endpoint
required
Scopes:
sign · Idempotency-Key required · confirmed_by requiredstring
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
There are exactly three legitimate callers:- The signing person’s own org key, naming that person in both
Contracts-Acting-Personandconfirmed_by, driven by a surface where that person just pressed a button. - That person’s own OAuth token, where the token is already the seat.
- 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.
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.
kindisdrawn,typedoruploaded. - 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_requiredwith 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.
DELETEis 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.
held · bound · void · withdrawn, in the app, over HTTP, and in the MCP tool list.
How a signature moves between them
Onlyvoid 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.
- Everyone agrees. Sam signs. The signature is
bound, live against the current words. - Somebody opens a new question on clause nine. The contract goes back to
negotiating, and Sam’s signature moves toheld. It is still good. Nothing about the words has changed yet, so nothing about the consent has either. It is just waiting. - If the question closes with the clause unchanged, the hash never moved and the signature goes
back to
bound. Sam does nothing. - 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.
agreed going back to negotiating is the backwards step that makes held reachable at all. See
Concepts.
Read the signatures
endpoint
Scopes:
contracts:readendpoint
Scopes:
contracts:read200 OK
void_reason is words_changed, party_removed or contract_cancelled.
Read the signature state
endpoint
Scopes:
contracts:read200 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
When a signature voids, the replacement is always a freshPOST /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 requiredRequest
withdrawn and fires signature.withdrawn.
- 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 returns403 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 toexecuted 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:read200 OK
urlis 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.recipeis 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 afiledcontract, because we never restate somebody else’s execution as ours.
Receipts
endpoint
Scopes:
contracts:read200 OK
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.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.”