---
title: "Review a draft"
summary: "Check every claim in a draft against a loaded batch. Attest extracts the claims itself, splits compound sentences, and returns one result per claim in draft order."
category: "developer"
tags: ["attest", "reference", "draft", "review", "extraction", "job", "polling"]
updated: "2026-09-14T04:25:14.723Z"
url: "https://support.quorumtech.ch/articles/attest-review-draft"
markdown: "https://support.quorumtech.ch/articles/attest-review-draft.md"
---

# Review a draft

`POST https://api.quorumtech.ch/attest/v1/attest/draft`

Review every claim in a draft.

Takes a whole draft, finds the claims in it, and checks each against a loaded batch. Coverage
is uniform: the claims a writer omits from a list are the ones they did not perceive as claims,
and those are where the errors are.

Name the draft by the `draftId` from `POST /attest/v1/drafts` (preferred) or send its text as
`draft`. A short review finishes inside the request and returns `200` with the full result. A
long one returns `202` with a `jobId` to poll at `GET /attest/v1/attest/draft/jobs/{jobId}`;
`config.waitMs` sets how long the request is held before that happens.

A draft review reserves its ceiling of claims up front, against both the daily budget and
the workspace's credits: the request's `config.maxClaims` or 200, bounded by the draft's
length when its text is in the request. The reserve shrinks to the claims found as soon as
extraction ends, and settles to the number actually checked once the result is returned or
collected. Set `maxClaims` to reserve less.

A review the day or the balance cannot cover in full but can cover part of is clamped
rather than refused: the ceiling becomes what is left and `X-Claims-Clamped` says so. Only
a review nothing is left for is refused. Whenever the ceiling stops a review short,
`summary.truncated` is true, `skippedPassages` lists what was left, and
`summary.nextCursor` passed back as `config.cursor` resumes it where it stopped, checking
nothing twice.

This is the call for reviewing a document rather than a sentence. Load the sources as a batch, upload the draft, and name both. Attest reads the draft in passages, extracts every assertion (counts, durations, sequence, comparisons, attributions, negations, and characterisations of what a document says), splits compound sentences, checks each claim against the batch, and returns the results in draft order, each carrying the passage it came from so you can find it in your text.

`config.waitMs` holds the request up to 120,000 milliseconds before a long review becomes a job; poll that job until `state` is `done`.

Coverage is reported, never assumed: `summary.failedChunks` and `failedPassages` name stretches nothing could read, `summary.truncated` says whether `maxClaims` cut the review short, and each claim's `coverage` says whether the batch could speak to it at all. Out-of-scope attention is not a finding against the draft.

Prefer `draftId` from an upload over `draft` text, so the review runs against the bytes you hold and the `draft.fileHash` in the result proves it.

```json
{
  "draftId": "dft_14f917371a7547dc8bd6",
  "batchId": "bat_8153abc164334cd38273",
  "config": { "maxClaims": 50, "waitMs": 20000 }
}
```

Replace the ids with the ones your uploads returned. The playground offers the most recent ones it has seen.

## Request

Provide `draftId` (preferred) or `draft`.

A JSON body with these fields:

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `draftId` | string | no | The id from `POST /attest/v1/drafts`. |
| `draft` | string | no | The draft's text, only for a caller that cannot upload a file. 1 to 200,000 characters. |
| `batchId` | string | yes | The batch to check the draft's claims against. |
| `glossary` | object of string | no |  |
| `config` | object | no |  |
| `config.maxClaims` | integer | no | The most claims to extract and check, and what the review reserves against the daily budget and the workspace's credits before it runs. When the draft's text is sent as `draft`, the reserve is also bounded by its length, and that smaller figure is the ceiling the review actually runs under. Extraction stops as soon as the passages read fill the ceiling, so nothing is extracted only to be discarded. Default `200`. 1 to 500. |
| `config.cursor` | string | no | `summary.nextCursor` from a previous review of this same draft. The review resumes at the passage where that one stopped, drops the claims of it already checked, and checks nothing twice. Loop until `nextCursor` is null. At most 40 characters. |
| `config.waitMs` | integer | no | How long to hold the request before answering 202 with a job to poll. 0 to 120,000. |

## Example request

```bash
curl -X POST "https://api.quorumtech.ch/attest/v1/attest/draft" \
  -H "Authorization: Bearer $QUORUM_API_KEY" \
  -H "content-type: application/json" \
  -d '{
  "draftId": "dft_14f917371a7547dc8bd6",
  "batchId": "bat_8153abc164334cd38273",
  "config": {
    "maxClaims": 50,
    "waitMs": 20000
  }
}'
```

## Responses

### 200

The review finished inside the request.

| Field | Type | Description |
| --- | --- | --- |
| `claims` | DraftClaimCheck[] |  |
| `claims[].index` | integer |  |
| `claims[].claim` | string | The claim as extracted from the draft. |
| `claims[].source` | string | The passage it was extracted from. |
| `claims[].ok` | boolean |  |
| `claims[].duplicateOf` | integer | Set when an earlier claim in this review has the same text. The result is that claim's, checked and charged once. |
| `claims[].kind` | `assertion` or `synthesis` or `judgement` |  |
| `claims[].coverage` | `in_scope` or `out_of_scope` | Whether the batch could speak to this claim at all. Out-of-scope attention is not a finding against the draft. |
| `claims[].missingTerms` | string[] | Terms from the claim that appear nowhere in the batch, when out of scope. |
| `claims[].result` | EvidenceModeResult or BatchModeResult |  |
| `claims[].error` | object |  |
| `claims[].error.code` | string |  |
| `claims[].error.message` | string |  |
| `summary` | object |  |
| `summary.total` | integer |  |
| `summary.supported` | integer |  |
| `summary.needsAttention` | integer |  |
| `summary.failed` | integer | Claims whose check could not run. Not the same as `failedChunks` |
| `summary.extracted` | integer | Claims found in the draft. |
| `summary.chunks` | integer | Passages the draft was read in |
| `summary.extractedChunks` | integer | Passages actually read. Fewer than `chunks` when the claim ceiling stopped extraction early. |
| `summary.failedChunks` | integer | Passages that could not be read. |
| `summary.skippedChunks` | integer | Passages left unchecked because the claim ceiling was reached. |
| `summary.truncated` | boolean | True when the claim ceiling was reached and part of the draft went unchecked. |
| `summary.nextCursor` | string | Where to continue when `truncated`: pass it back as `config.cursor` with the same draft and batch. Null when the whole draft was read. May be null. |
| `summary.nextOffset` | integer | The character offset in the draft where the next review starts. Null when the whole draft was read. May be null. |
| `summary.duplicates` | integer | Claims whose text repeated an earlier claim |
| `summary.cached` | integer | Claims answered from a measurement already made |
| `summary.usage` | Usage | What this response cost at the model provider. Zeros for a replay. |
| `summary.usage.calls` | integer | Completions made. |
| `summary.usage.promptTokens` | integer | Tokens sent. |
| `summary.usage.completionTokens` | integer | Tokens returned. |
| `summary.usage.cachedTokens` | integer | Prompt tokens served from the provider's cache. |
| `summary.usage.latencyMs` | integer | Wall time waiting on the provider. |
| `summary.usage.fellBack` | integer | Calls the preferred tier shed to the fallback. |
| `summary.attentionInScope` | integer | Flagged claims the batch could speak to. |
| `summary.attentionOutOfScope` | integer | Flagged claims the batch could not speak to. |
| `summary.attentionByKind` | object |  |
| `summary.attentionByKind.assertion` | integer |  |
| `summary.attentionByKind.synthesis` | integer |  |
| `summary.attentionByKind.judgement` | integer |  |
| `failedPassages` | FailedPassage[] |  |
| `failedPassages[].chunk` | integer |  |
| `failedPassages[].excerpt` | string |  |
| `failedPassages[].reason` | string |  |
| `skippedPassages` | FailedPassage[] | The passages the claim ceiling left unchecked. Nothing went wrong with them; they are where the next review starts. |
| `skippedPassages[].chunk` | integer |  |
| `skippedPassages[].excerpt` | string |  |
| `skippedPassages[].reason` | string |  |
| `draft` | DraftSummary | Which bytes were reviewed. |
| `draft.id` | string | The draft id to review by. `inline` when the text came in the request. |
| `draft.filename` | string | May be null. |
| `draft.fileHash` | string | SHA-256 of the draft as uploaded, or of the text as sent. |
| `draft.chars` | integer |  |
| `draft.createdAt` | string (date-time) |  |
| `draft.source` | `upload` or `inline` |  |

### 202

The review is still running. Poll the job.

| Field | Type | Description |
| --- | --- | --- |
| `jobId` | string |  |
| `state` | `running` |  |
| `progress` | JobProgress |  |
| `progress.extracted` | integer | Claims found so far. It grows as each passage is read, and is final once `phase` leaves `extracting`. |
| `progress.checked` | integer | Claims checked so far |
| `progress.passagesDone` | integer | Passages read so far |
| `progress.passagesTotal` | integer |  |
| `progress.phase` | `extracting` or `checking` or `finished` |  |
| `progress.failedPassages` | FailedPassage[] |  |
| `progress.failedPassages[].chunk` | integer |  |
| `progress.failedPassages[].excerpt` | string |  |
| `progress.failedPassages[].reason` | string |  |
| `updatedAt` | string (date-time) | When the counts last changed. Progress that has not moved for minutes is a long passage or a dead job, and this says which. |
| `draft` | DraftSummary | Which bytes were reviewed. |
| `draft.id` | string | The draft id to review by. `inline` when the text came in the request. |
| `draft.filename` | string | May be null. |
| `draft.fileHash` | string | SHA-256 of the draft as uploaded, or of the text as sent. |
| `draft.chars` | integer |  |
| `draft.createdAt` | string (date-time) |  |
| `draft.source` | `upload` or `inline` |  |
| `poll` | string | Where to collect the result, as a full URL through the gateway. |
| `events` | string | Where to watch the review as it runs, as a full URL through the gateway. |

## Errors

The body is `{ "error": { "code", "message" } }`; the full catalogue with what to do about each code is on the Errors page.

| Status | Codes | When |
| --- | --- | --- |
| 400 |  | The request does not match the schema. `error.issues` names each failing field. |
| 401 |  | The key is missing, not recognised, or revoked. |
| 402 | `payment_required`, `credits_exhausted`, `remaining`, `needed` | The workspace has no active API subscription (`payment_required`), or is prepaid and has no credits left for this request (`credits_exhausted`, with `remaining` and `needed`). Manage billing and add credits at https://developer.quorumtech.ch/billing. |
| 403 |  | The key is not scoped to Attest. |
| 404 | `batch_not_found`, `draft_not_found` | The batch (`batch_not_found`) or the draft (`draft_not_found`) named in the request is not loaded. Both age out; upload again. |
| 410 | `inputs_deleted` | The draft or the batch was deleted while the review was running (`inputs_deleted`). Upload again and start a new review. |
| 429 | `rate_limited`, `claim_budget_exhausted`, `too_many_running` | Over a limit (`rate_limited`, `claim_budget_exhausted`), or the server has no room for another review right now (`too_many_running`); `Retry-After` says when to try again. |
| 502 |  | The model provider behind Attest failed. Retry. |
| 503 | `extraction_failed`, `not_configured` | No part of the draft could be read (`extraction_failed`), or the evaluator is not configured (`not_configured`). |

## Cost

Reserves `config.maxClaims` (default 200, at most 500) at two credits ($0.02) each when the request is accepted, and settles to two credits per claim actually checked when the result is returned or collected. The difference goes back. Set `maxClaims` to reserve less.
