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.
There is one permission model for humans and agents. Not two. Everything below is a way of naming which person a call acts as, because every write on contracts.io is attributable to a person.

Step zero: find out who you are

Every write names the person it acts for. Before you can write anything, you need that person’s id, and this is the call that hands it to you. Make it first, every time, with any credential.
GET /me needs no scope. Any working credential may call it, which is the point: it is how you find out what you are holding. parties returns at most 50 rows with has_more and next_cursor. A person on four thousand contracts is normal, and this call has to stay small. Use GET /contracts when you want the whole list. effective_rung is already the answer. It folds the person’s own rung, the org ceiling and any per-contract arming into one number, so you never recompute it yourself. See the autonomy ladder.

Base URL and version

There is no /v1/ in the path. Versions are date-pinned per key and overridable per call:
A call with no Contracts-Version header uses the version pinned to the key.

Org keys

Secret keys are scoped to one org and sent as a bearer token.
Test keys are sk_test_… and live keys are sk_live_….
An org key must name the human it acts for on every write.
A key with no acting person can read org-wide and write nothing. This is not a convenience header; it is how a decision gets a person_id, how rate limits get charged to a seat, and how the audit trail stays truthful about who moved a word.

Per-person tokens

For third-party apps, use OAuth. The resulting token is the seat, and three separate things travel with it: Capacity is not configurable by an admin. It is a fact about the contract, not a setting. A guest is a person with no account and no org. Somebody sent them a contract and they can negotiate it and sign it. That is not a lesser seat, it is the normal way the other side arrives. Reading uses the link. The token rides as a query parameter, so the link somebody was emailed just works:
Writing uses a header. One form, on every write:
Six things to know, and then a guest write is just a write.
  1. A write never carries the token in the URL. Web addresses end up in server logs, in browser history and pasted into support tickets. A write sent with ?t= and no header gets 401 unauthorized.
  2. The token names one contract and one person, and it supplies both. Do not send Contracts-Acting-Person. Sending one gets 400 invalid_request.
  3. The token is the confirmation. Do not send confirmed_by either. The link was mailed to that person, so somebody holding it is somebody who opened it. Sending confirmed_by gets 400 invalid_request rather than being quietly believed.
  4. The link buys exactly what the contract gives that person. If they were invited to negotiate, they can counter and reply. If they were invited to sign, they can sign. Nothing reaches any other contract, and POST /ask is never available to a guest.
  5. If-Match and Idempotency-Key work exactly as they do for a key. Same headers, same statuses, same 409 words_changed body when the words moved.
  6. Removing a guest from the contract kills their link. An expired or revoked token returns 401 unauthorized, never 403, so somebody who was removed never learns whether the contract is still there.
There is still no guest key issuance endpoint, ever. A link token is not a key. It has no scopes, it cannot be listed, and it cannot be minted through the API. It is a contract and a person, and that is the whole grant. Guests are also never issued MCP connections.

MCP agents inherit the connecting person

An MCP agent connects as the person who connected it and holds that person’s rung, capped by the org ceiling, and capped additionally at cannot send, cannot sign. One rule, no second permission model for agents.
Do not authenticate an MCP server with an org key. An org key must name the human it acts for on every write, and MCP already has one: the person who connected it.

Scopes

imports:write and ask:read are new (reconciled 2026-08-24), and both are narrowing splits rather than additions. assets:write was carrying two different powers: attaching a rate card, and parsing a contract into a live contract. That is why importing got its own grant. ask:read came out of contracts:read because a corpus-wide question crosses every contract the seat can open, and a key granted to read one contract should not silently read across the org.Neither widens anything: retrieval and reads stay capped by the acting person’s row-level access. The scope decides whether you may ask; the seat decides what can answer.
send and sign are grantable. A key holding both still requires confirmed_by on the call. The scope decides whether you may ask; the confirmation decides whether it happens. See the never-sends 409.

Rate limits

Per org, with agent traffic counted against the acting person. A runaway harness throttles one seat, not the company. A 429 carries Retry-After.

Idempotency

Idempotency-Key is required on every POST that creates or moves a record: send, replies, signatures, POST /contracts, POST /imports and POST /assets. Replays return the original response.
Generate one key per attempt, not one per candidate. If you reuse a key across two genuinely different sends, the second one either replays the first response, which means people you never wrote to look like they were written to, or it fails with idempotency_error. Both are worse than a fresh UUID per try.

Concurrency

Calls that can move words or commit a round take the contract’s current words hash:
A stale hash returns 409 words_changed with the new hash and the decisions that must be re-asked. It is the signing compare-and-swap, generalized to every write. See Errors. You never need an extra read to find that hash. Every call that changes anything returns words_hash in its own response body. That value is what you send as If-Match on the next call.

Every write, and the three headers it takes

If-Match is required only on calls that can move words or commit a round. It is accepted and ignored on edits to your own private draft, and it does not apply where there is no contract hash yet. A word-moving call sent with no If-Match at all returns 428 if_match_required. That is a different failure from 409 words_changed, which means you sent one and it was out of date.
Signing is the one compare carried in the body. POST /contracts/{id}/signatures takes text_hash as a field instead of If-Match as a header, because there the hash is not a cache check. It is the thing being signed. It goes onto the signature, onto the certificate, and it is what decides years later whether that signature is still live. A value that ends up on a legal record belongs in the body where a reader can see it. Sending If-Match to the sign endpoint returns 400 invalid_request, so nobody sends both and wonders which one won.

What confirmed_by actually checks

confirmed_by names the person who pressed the button. It is checked on the server, not taken on trust, on send, replies, signatures and signature withdrawal.
  1. The person is on the contract’s required-party list. If not: 403 not_on_required_party_list.
  2. The person holds the right capacity: negotiating for send and replies, signing for signatures and withdrawals. If not: 403 not_a_negotiator or 403 not_a_signer.
  3. The person is in your own org. A key can never confirm as the other side.
  4. The value is written onto the letter, the signature and the certificate, so “who pressed this” stays answerable from the record itself.
A key naming the same person it acts for is fine, and is what somebody running their own script looks like. What is refused is a key confirming somebody who could not have done the act.