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.
Import is how an existing corpus enters contracts.io. Every import produces two objects: an immutable provenance asset and a new contract.

Scopes, per endpoint

Every call on this page carries its own scope. One blanket grant would mean a key that can start an import cannot poll the job it started. Reading an import row is capped by access to the contract it produced. The scope opens the door. The seat decides what is behind it, exactly as everywhere else in this API.

Upload and parse

endpoint
required
Scopes: imports:write · multipart · Idempotency-Key required
Accepts docx, pdf, txt, md, at ≤25MB each and ≤100 per call.
Idempotency-Key is required here. This is the largest, slowest write in the API, and it mints contracts. One socket timeout on a 100-file batch mints 100 duplicate contracts, there is no bulk delete, and those duplicates then pollute every search and every count you build on the corpus. A replay of the same key returns the original batch response.
This is the only import path (reconciled 2026-08-24). An earlier two-step form, POST /assets then POST /contracts/import, appeared in the design drafts and is superseded. It never ships. POST /assets remains, narrowed to its real job: attaching a file that is not becoming a contract.
202 Accepted
Mass import is this endpoint batched. There is no separate bulk pipeline, because a hundred imports that behave differently from one import is a hundred untested paths.

When the upload itself is refused

These three fire before any parsing starts, so they come back on the POST and not as an event. A file that was accepted and then produced nothing fires import.failed with a reason from a closed list: unsupported_format · file_too_large · extraction_empty · not_a_contract · parser_error · virus_detected. See When an import fails.

Poll an import

endpoint
Scopes: contracts:read
200 OK
States: queued · parsing · parsed · failed.
unverified_count is the integer and unverified[] is the block ids. They used to be one field with two shapes across surfaces, so a typed client generated from one broke against the other. Both are useful, so both are here, under two names. The import.completed webhook payload carries both.

The three counts

The line this read exists to render is: “Read it, 14 fields, 2 signature blocks, 3 outside your playbook.” Those are three separate numbers and each one answers a different question.

fields

How many blanks the parse filled. That is facts_inferred plus the facts it could not infer.

signature_blocks

How many places somebody signs. Blocks now carry a kind of clause, signature_block, heading or exhibit, which is where this number comes from.

outside_playbook[]

Which blocks sit outside your own rules, with the rule each one was measured against.
outside_playbook[] carries objects, not a count, so the number on the screen is checkable rather than asserted. Each row is { block, rule, term }. A rule of null means the block has no rule at all, which is a different kind of “outside your playbook” and worth showing separately: one is a term you disagree with, the other is a term you have never had a position on.
endpoint
Scopes: contracts:read · the same rows, cursor-paginated
A parse that produced nothing fires import.failed and mints no contract. contract.parsed stays the success event.

Already-signed documents

Most of what a company has in a drawer was signed somewhere else, years ago, on paper. Import it with one flag:
202 Accepted
The contract that arrives on contract.parsed is in state filed:
GET /contracts/con_12
Why filed cannot be signed here. Our signing surface can only bind our own copy of the words, and our copy is not what anybody put their name to. A fresh press against our hash would forge consent against text nobody signed. So we never offer one.
filed is a terminal state. What it is, and what it refuses:
  • A record, not a negotiation. It has blocks, facts, spans and a source asset, and it is fully indexed, retrievable and citable. Asking your contracts across a drawer full of signed contract is the whole reason to import it, and that keeps working.
  • Never negotiable. POST /contracts/{id}/send, /decisions, /replies and /invite all return 409 contract_filed. There are no parties to negotiate with, because nobody was invited to anything.
  • Never signable. POST /contracts/{id}/signatures returns 409 contract_filed.
  • No signature objects and no certificate. GET /contracts/{id}/signatures returns an empty list, and GET /contracts/{id}/certificate returns 404 not_found. What was signed lives in the asset, as pixels, exactly as it arrived. We do not restate somebody else’s execution as ours.
  • signed_elsewhere records what you told us, marked as a claim.
    It is provenance, never proof. Nothing in permissions or ready-checks reads it.
  • POST /contracts/{id}/amend is the door out. Amending a filed contract mints a normal negotiable contract that names its parent through amends. That is how a renewal of something signed years ago on paper begins.
The alternative was an evidence-only signature object that visibly binds no hash. It was refused, because a signature object that is not a signature is exactly the shape of a thing somebody will later count. “Who signed” must never have two answers.

The two objects

The asset

Immutable, checksummed, kept forever with its extracted text layer. The provenance record.

The contract

The working representation. Blocks, facts, spans. The thing you negotiate.
Every block links back to where it came from:
block.source_span
So “show me this in the original” is a lookup, never a re-parse.

Branding and layout are never imported

The asset keeps the look. The contract keeps the words.Extraction discards fonts, colours, logos, headers, footers and page furniture. A logo in the source PDF stays asset pixels forever and can never become a block.
Two load-bearing reasons: the contract never wears a party’s brand, and imported layout is the fastest possible way to make a parse look authoritative when a parse is only ever a claim.

Verification, two surfaces

A parse is fallible and the payload says so. There are exactly two things to check.

1. Inferred facts

Every extracted fact arrives with inferred: true until you clear it:
endpoint
Scopes: contracts:read · no If-Match
200 OK
  • The scope is contracts:read. Agreeing with what the parse already said writes no words.
  • It takes no If-Match, for the same reason.
  • It works at any point in the contract’s life, before or after the first Send.
Eight facts checked instead of fourteen pages re-read.
Changing a fact is a different call. POST /contracts/{id}/facts/{fact_id} with a { "value": ... } body moves words before the first Send, and stages an ask after it. Confirm only agrees with what is already there.A fact whose value appears nowhere in the words returns 400 fact_has_no_span. A fact the words do not carry is metadata, and this API has none.

2. Unverified blocks

Each block carries a confidence (0 to 1) and a verification of verified or unverified. Blocks under the threshold come back unverified:
endpoint
Scopes: contracts:read
200 OK
Ordered by consequence, not by confidence: an unsure liability cap outranks an unsure notice address.

Confirming or correcting a block

endpoint
Scopes: contracts:write before first Send · contracts:read after
Request
Omit text to confirm the parse as it stands. Supply it, before the first Send only, to settle the span on a different value the parse already offered.
Confirm settles a field. It does not write words. Three limits, all enforced:
  1. text must be a value the parse or a fill already proposed for that span. It selects among candidates; arbitrary prose returns 400 not_a_parse_candidate. A confirm that accepted any string would be PUT /contracts/{id}/blocks/{id} with a friendlier name, which is the write that does not exist.
  2. text is accepted before the first Send only. That is the whole licence: nobody else holds the words yet, exactly as with filling a fact.
  3. Never unattended. confidence is a flag, not a decision-maker. There is no “auto-confirm everything above 0.9”, and there will not be one.
After the first Send, a confirm carrying text returns 400 invalid_request, and the scope for the post-Send form is contracts:read, because a verification writes nothing. It does not stage a counter, it does not mint an ask, and it does not need decisions:write.To change a word after Send, stage a decision like everything else: POST /contracts/{id}/decisions with a block_id, a type of counter, the new text and a why. That opens an ask the other side can see, which is the only way a word ever moves.
Nothing may quietly “fix” a block on a sent contract. Not an agent, not a key, not a parser rerun.

Sending with unverified content

Sending a contract with unconfirmed facts or unverified blocks returns a warning, not a refusal. The same principle in both cases: make the check cheap, do not hold the user hostage.

One adapter, formats behind it

Everything downstream, meaning parse, blocks, facts, spans and confidence, sees only extract’s output. Adding rtf or html later is one file and no schema change, and no format can smuggle styling into the sheet, because the interface has nowhere to put it.

Assets

An asset is a file bound to a contract or to a relationship: the source PDF, an exhibit, a rate card, the .eml of a forwarded thread. Assets are fuel and provenance both.
endpoint
required
Scopes: assets:write · multipart · Idempotency-Key required
201 Created
Exactly one of contract_id or relationship_id is required, plus a role. Neither, or both, returns 400 invalid_request. Binding to a contract implies its relationship, which is why the response carries both ids.Provenance that floats is not provenance. A source PDF nobody can trace to a contract is a file in a bucket.
role is one of provenance · exhibit · rate_card · attachment. An asset minted by POST /imports is auto-bound to the contract it produced, with role: "provenance". You do not attach it yourself.

Reading assets back

endpoint
Scopes: contracts:read · everything bound to this contract
endpoint
Scopes: contracts:read · the contracts’ assets plus the relationship’s own
Assets accept a wider format list than imports do, because storing a file is not parsing one: docx · pdf · txt · md · eml · png · jpg · csv · xlsx, 25MB each.

A mail thread is provenance, not a contract

The .eml rides POST /assets, never POST /imports. Store the thread as an asset with role: "provenance", bound to the contract the attachment produced. Its messages seed the contract’s imported letters, which weigh nothing in any tally.The attachment inside the thread is what goes through POST /imports and becomes the words. A forward has two payloads and they go to two places.
One import path, settled (reconciled 2026-08-24). POST /assets stores a file and stops there. It does not parse, and there is no POST /contracts/import to hand the asset to.Turning a document into a contract is always POST /imports, which takes the file directly and can batch. Two paths would mean two parse pipelines, two verification stories and two sets of webhooks to keep honest.

Not at MVP

OCR of scans · DOCX round-trip export · table structure · multi-document packs from one upload.