> ## Documentation Index
> Fetch the complete documentation index at: https://docs.contracts.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Imports

> Upload a document, get back a provenance asset and a working contract. The asset keeps the look; the contract keeps the words.

<Warning>
  **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](/glossary) maps them.
</Warning>

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.

| Endpoint                                         | Scope                                                        |
| ------------------------------------------------ | ------------------------------------------------------------ |
| `POST /imports`                                  | `imports:write`                                              |
| `GET /imports/{id}`                              | `contracts:read`                                             |
| `GET /imports?batch=`                            | `contracts:read`                                             |
| `POST /contracts/{id}/blocks/{block_id}/confirm` | `contracts:write` before first Send · `contracts:read` after |
| `POST /contracts/{id}/facts/{fact_id}/confirm`   | `contracts:read`                                             |
| `POST /assets`                                   | `assets:write`                                               |

**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

<ResponseField name="POST /imports" type="endpoint" required>
  Scopes: `imports:write` · multipart · `Idempotency-Key` **required**
</ResponseField>

Accepts `docx`, `pdf`, `txt`, `md`, at **≤25MB each and ≤100 per call**.

<Warning>
  **`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.
</Warning>

<Note>
  **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](#assets).
</Note>

```bash theme={null}
curl -X POST $API/imports \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING" \
  -H "Idempotency-Key: e30f8a1c-..." \
  -F "files[]=@northwind-msa.pdf" \
  -F "files[]=@helix-msa.docx"
```

```json 202 Accepted theme={null}
{
  "batch": "imb_1",
  "imports": [
    { "id": "imp_7", "asset": "ast_1", "state": "parsing" },
    { "id": "imp_8", "asset": "ast_2", "state": "parsing" }
  ]
}
```

<Note>
  **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.
</Note>

### 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.

| Code                 | Status | Fires when                                 |
| -------------------- | ------ | ------------------------------------------ |
| `unsupported_format` | `415`  | A file outside `docx`, `pdf`, `txt`, `md`. |
| `file_too_large`     | `413`  | One file over 25MB.                        |
| `too_many_files`     | `400`  | More than 100 files in one call.           |

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](/api/errors#when-an-import-fails).

## Poll an import

<ResponseField name="GET /imports/{id}" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

```json 200 OK theme={null}
{
  "id": "imp_7",
  "state": "parsed",
  "asset": "ast_1",
  "contract": "con_9",
  "blocks": 34,
  "unverified_count": 3,
  "unverified": ["blk_4", "blk_18", "blk_29"],
  "fields": 14,
  "signature_blocks": 2,
  "outside_playbook": [
    { "block": "blk_9",  "rule": "pbk_3", "term": "liability_cap" },
    { "block": "blk_14", "rule": "pbk_5", "term": "notice_days" },
    { "block": "blk_22", "rule": null,    "term": "assignment" }
  ],
  "facts_inferred": 8,
  "parser": { "model": "...", "prompt_hash": "..." }
}
```

States: `queued` · `parsing` · `parsed` · `failed`.

<Note>
  **`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.
</Note>

### 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.

<CardGroup cols={3}>
  <Card title="fields" icon="text-cursor-input">
    How many blanks the parse filled. That is `facts_inferred` plus the facts it could not infer.
  </Card>

  <Card title="signature_blocks" icon="signature">
    How many places somebody signs. Blocks now carry a `kind` of `clause`, `signature_block`, `heading`
    or `exhibit`, which is where this number comes from.
  </Card>

  <Card title="outside_playbook[]" icon="triangle-alert">
    Which blocks sit outside your own rules, with the rule each one was measured against.
  </Card>
</CardGroup>

`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.

<ResponseField name="GET /imports?batch=imb_1" type="endpoint">
  Scopes: `contracts:read` · the same rows, cursor-paginated
</ResponseField>

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:

```bash theme={null}
curl -X POST $API/imports \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING" \
  -H "Idempotency-Key: 71b0c4de-..." \
  -F "files[]=@fernbrook-nda-signed.pdf" \
  -F "already_signed=true"
```

```json 202 Accepted theme={null}
{
  "batch": "imb_2",
  "imports": [ { "id": "imp_9", "asset": "ast_4", "state": "parsing" } ]
}
```

The contract that arrives on `contract.parsed` is in state **`filed`**:

```json GET /contracts/con_12 theme={null}
{ "id": "con_12", "state": "filed", "origin": "import" }
```

<Warning>
  **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.
</Warning>

`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`](/api/errors). 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.**

  ```json theme={null}
  { "signed_elsewhere": { "claimed_by": "per_9", "claimed_at": "2026-08-24T08:00:00Z", "asset": "ast_4" } }
  ```

  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.

<Note>
  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.**
</Note>

## The two objects

<CardGroup cols={2}>
  <Card title="The asset" icon="file-lock">
    Immutable, checksummed, kept forever with its extracted text layer. The provenance record.
  </Card>

  <Card title="The contract" icon="file-text">
    The working representation. Blocks, facts, spans. The thing you negotiate.
  </Card>
</CardGroup>

Every block links back to where it came from:

```json block.source_span theme={null}
{ "asset": "ast_1", "page": 4, "offset_start": 412, "offset_end": 486 }
```

So *"show me this in the original"* is a lookup, never a re-parse.

## Branding and layout are never imported

<Warning>
  **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.
</Warning>

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:

<ResponseField name="POST /contracts/{id}/facts/{fact_id}/confirm" type="endpoint">
  Scopes: `contracts:read` · no `If-Match`
</ResponseField>

```bash theme={null}
curl -X POST $API/contracts/con_9/facts/fct_cap/confirm \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING"
```

```json 200 OK theme={null}
{
  "id": "fct_law",
  "value": "England & Wales",
  "inferred": false,
  "occurrences": ["blk_12"],
  "confirmed_by": "per_9",
  "words_hash": "sha256:be71..."
}
```

* **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.

<Note>
  **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.
</Note>

### 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`:

<ResponseField name="GET /contracts/{id}/unverified" type="endpoint">
  Scopes: `contracts:read`
</ResponseField>

```json 200 OK theme={null}
[
  {
    "block": "blk_9",
    "confidence": 0.62,
    "extracted_text": "...capped at six (6) months' fees...",
    "source": { "asset": "ast_1", "page": 4, "offset_start": 412, "offset_end": 486 }
  }
]
```

Ordered **by consequence, not by confidence**: an unsure liability cap outranks an unsure notice address.

### Confirming or correcting a block

<ResponseField name="POST /contracts/{id}/blocks/{block_id}/confirm" type="endpoint">
  Scopes: `contracts:write` before first Send · `contracts:read` after
</ResponseField>

```json Request theme={null}
{ "text": "...capped at six (6) months' fees..." }
```

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.

<Warning>
  **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](/api/replies#what-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.
</Warning>

<Warning>
  **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.
</Warning>

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

```ts theme={null}
interface SourceAdapter {
  sniff(head: Buffer): boolean
  extract(bytes: Buffer): { text: string; pages: Page[]; offsets: OffsetMap }  // words only
  locate(span: Span): SourceSpan                                              // → page + offsets
}
```

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.

<ResponseField name="POST /assets" type="endpoint" required>
  Scopes: `assets:write` · multipart · `Idempotency-Key` required
</ResponseField>

```bash theme={null}
curl -X POST $API/assets \
  -H "Authorization: Bearer $CONTRACTS_KEY" \
  -H "Contracts-Acting-Person: $ACTING" \
  -H "Idempotency-Key: 61aa7f20-..." \
  -F "file=@thread.eml" \
  -F "contract_id=con_9" \
  -F "role=provenance"
```

```json 201 Created theme={null}
{
  "id": "ast_4",
  "pages": 1,
  "state": "stored",
  "role": "provenance",
  "contract": "con_9",
  "relationship": "rel_4",
  "checksum": "sha256:1c0d...",
  "content_type": "message/rfc822",
  "filename": "thread.eml",
  "bytes": 184320
}
```

<Warning>
  **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.
</Warning>

`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

<ResponseField name="GET /contracts/{id}/assets" type="endpoint">
  Scopes: `contracts:read` · everything bound to this contract
</ResponseField>

<ResponseField name="GET /relationships/{id}/assets" type="endpoint">
  Scopes: `contracts:read` · the contracts' assets plus the relationship's own
</ResponseField>

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

<Note>
  **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.
</Note>

<Info>
  **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`](#upload-and-parse), 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.
</Info>

## Not at MVP

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