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.

The envelope

Every error returns the same shape. There are no exceptions, including the signing conflict, which carries two extra fields beside the envelope and never instead of it.
string
The stable, machine-readable identifier. Branch on this, never on message.
string
The coarse family, for logging and for deciding whether a whole class is worth retrying. One of invalid_request_error, authentication_error, permission_error, conflict_error, rate_limit_error, api_error.
string
The field at fault, when there is one.
string
A link into this page. Surface it in your own error UI.
doc_url has one format: https://docs.contracts.io/errors# followed by the code with its underscores turned into hyphens. words_changed becomes .../errors#words-changed. Every code is a section on this one page. There is no page per code, so a link built any other way is a dead link in your product.

The state codes

These six are the ones that mean the negotiation itself disagreed with you. They are the codes worth writing real handling for.

The codes you hit while wiring up

Nothing on this list is interesting once your integration works. All of it is what the first afternoon looks like, so it belongs on the page rather than in a support thread.
404 is the privacy answer. A contract you are not on and a contract that never existed return exactly the same body. If they differed, somebody outside could guess ids and sort the real ones from the invented ones just by reading the status. The one exception is a contract you can see where you lack the capacity for the act, which is a 403, because on a contract you hold, who is required for what is public to both sides.

When an import fails

import.failed carries a reason from a closed list, so you can count and route a hundred-file batch without reading prose.

confirmation_required

Status 409. This is the never-sends rule as a status code, and it is the whole agent story in one response.
Returned by POST /contracts/{id}/send, POST /contracts/{id}/replies, and POST /contracts/{id}/signatures when confirmed_by is absent.
409
The response carries a fully assembled letter preview. Everything a person needs to look at before deciding. That is the point: the call did not fail, it arrived at a human. Recovering: put letter_preview in front of a person. When they press the button, call again with confirmed_by set to their per_….
Do not retry this. It is not a rate limit and not a transient failure. It is the end of the road, and the next actor is a person. There is no flag, key, scope, rung, or org setting that removes it. Holding the send and sign scopes does not exempt a key.

words_changed

Status 409. The generalized compare-and-swap. Every mutating call carries If-Match: <contract.words_hash>; a stale hash lands here.
409 on a reply
409 on a signature
object | null
The signer’s previous signature on this contract, if they had one, with its true state. When the words moved under it, that state is void. When this signer had no earlier signature, your_signature is null.The press you just submitted is always rejected outright. Nothing is half-created, so there is never a signature in some fifth in-between state to clean up.
Recovering:
1

Take the new hash

current_hash is the value to send as If-Match next time.
2

Re-decide only what was invalidated

re_asked[] names the decisions a concurrent letter killed. Everything not listed is still staged and still good. Do not rebuild the whole round.
3

For signatures, collect a fresh one

A signature survives a counter as held; it voids only when the words mint a new hash. The replacement is always a fresh POST /contracts/{id}/signatures against the hash the signer read. Never migrate consent onto text nobody saw.
One 409 covers both stale cases, and there is no 412 (reaffirmed 2026-08-24). This note stands deliberately and is not an oversight to be tidied away.The two cases are a stale If-Match on a mutating call and a stale text_hash on a signature. A 412 Precondition Failed would be the conventional status for the first; this API declines it so that one branch in your code handles every “the contract moved under you” case with the same current_hash payload. Two statuses for one condition would buy protocol tidiness and cost a caller the single recovery path.

Seat errors: 403

Two codes, and the difference is worth reading carefully.
403
The acting person is not a party to this contract at all. They cannot read it, decide on it, or sign it. This is not a missing scope. It is a fact about the contract.
403
The acting person is on the contract, but without negotiator capacity. A signer-only party can read and sign; they cannot stage or send decisions.
403
The acting person is on the contract, but without signer capacity. A negotiator-only party can counter and reply all day; they cannot press. This also fires when somebody tries to withdraw a signature that is not theirs, because only the person who pressed it, or their own org acting for them, may take it back.
Do not “fix” these by widening a key’s scopes. Capacity comes from the contract’s required-party list, not from the API key, and it is public. Both sides see who is required for what. The fix is a POST /contracts/{id}/invite performed by someone who holds the authority to invite.
A 403 here never leaks org authority. The counterparty must never learn that Acme made its junior get a review, so error messages describe capacity, which is public, and never authority, which is not.

ask_voided_by_counter: 409

You decided on an ask that a counter has already superseded. A counter voids all prior accepts, sent and unsent, by resetting the unanimity window. Recovering: re-fetch GET /contracts/{id}/letters/latest, find the live ask on that block, and decide on that one instead.

contract_executed: 409

The contract is terminal. No further decisions, signatures, invites or cancellations are possible. Recovering: POST /contracts/{id}/amend mints a new contract with the same parties.

Rate limits: 429

Per org, and agent traffic counts against the acting person. A runaway harness throttles one seat, not the company. A 429 carries Retry-After; honour it with backoff. 429 is the one status on this page that is safe to retry.

contract_filed: 409

This contract is a record of an agreement that was signed somewhere else, before it ever reached us. You can read it, search it and cite it. You cannot send it, negotiate it or sign it, because our signing surface can only bind our own copy of the words, and our copy is not what anybody put their name to. Recovering: if you want to renew or vary it, POST /contracts/{id}/amend mints a fresh negotiable contract that points back at this one. That is how a renewal of something signed years ago on paper begins. See Imports.

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, so a network timeout on a send is safe to repeat with the same key.
One key per attempt, not one per candidate. Reusing a key across two genuinely different sends is the failure worth designing against: either the second call replays the first response, so people you never wrote to look like they were written to, or it fails with idempotency_error and your handler has never seen that code.
Retrying a confirmation_required with the same idempotency key does not help. Nothing was recorded, because nothing happened. A person still has to press the button.