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 requireddocx, pdf, txt, md, at ≤25MB each and ≤100 per call.
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 thePOST 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:read200 OK
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-paginatedimport.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
contract.parsed is in state filed:
GET /contracts/con_12
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,/repliesand/inviteall return409 contract_filed. There are no parties to negotiate with, because nobody was invited to anything. -
Never signable.
POST /contracts/{id}/signaturesreturns409 contract_filed. -
No signature objects and no certificate.
GET /contracts/{id}/signaturesreturns an empty list, andGET /contracts/{id}/certificatereturns404 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_elsewhererecords what you told us, marked as a claim.It is provenance, never proof. Nothing in permissions or ready-checks reads it. -
POST /contracts/{id}/amendis the door out. Amending a filed contract mints a normal negotiable contract that names its parent throughamends. 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.
block.source_span
Branding and layout are never imported
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 withinferred: true until you clear it:
endpoint
Scopes:
contracts:read · no If-Match200 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.
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 aconfidence (0 to 1) and a verification of verified or unverified. Blocks
under the threshold come back unverified:
endpoint
Scopes:
contracts:read200 OK
Confirming or correcting a block
endpoint
Scopes:
contracts:write before first Send · contracts:read afterRequest
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.
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 awarning, not a refusal. The
same principle in both cases: make the check cheap, do not hold the user hostage.
One adapter, formats behind it
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 required201 Created
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 contractendpoint
Scopes:
contracts:read · the contracts’ assets plus the relationship’s owndocx · 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.