---
title: "Pay Documents and Batches"
description: "Turn boleto PDFs, Pix QR images, Pix keys and CSV or XLSX lists into one batch, review what the rail resolved, confirm the exact list and pay it, with a result per item. Same contract over the API, the TypeScript and Python SDKs, the CLI and the `@codespar/mcp` server."
---

import { Tab, Tabs } from "fumadocs-ui/components/tabs";
import { Callout } from "fumadocs-ui/components/callout";

<MetaStrip items={[
  { label: "TIME", value: "~25 min" },
  { label: "STACK", value: (<><ServerChip name="@codespar/sdk" accent /><ServerChip name="codespar (Python)" /><ServerChip name="@codespar/cli" /><ServerChip name="@codespar/mcp" /><ServerChip name="curl" /></>) },
  { label: "RAILS", value: (<><ServerChip name="boleto" /><ServerChip name="Pix" /></>) },
]} />

Send the files a person already has (a boleto PDF, a photo of a Pix QR, a spreadsheet of payments) or the charges they state in text, and get back one batch: every charge found, each resolved on the rail before anyone confirms. The person pays the exact list they reviewed, and every line comes back with its own result.

<EventPipeline
  title="FROM DOCUMENT TO PAID LIST"
  subtitle="Read locally, resolve on the rail, confirm the exact list, pay each item through the governed cycle"
  nodes={[
    { tag: "1 · FILE", name: "Upload, read in the request", meta: "POST /v1/payables/files", tone: "event" },
    { tag: "2 · BATCH", name: "Items resolved on the rail", meta: "POST /v1/payables/batches", tone: "validation" },
    { tag: "3 · PAY", name: "Confirm version + list_hash", meta: "POST /v1/payables/batches/:batchId/pay", tone: "loop" },
  ]}
  loopSteps={[
    { tool: "boleto", description: "quoted on the rail: amount, beneficiary, masked document, due date" },
    { tool: "Pix key", description: "owner name and masked document from the DICT; the amount comes from the person" },
    { tool: "Pix QR / copia e cola", description: "decoded with its CRC; a dynamic code through the rail's own decode" },
  ]}
  note="no model reads the file and the file is not kept: only its sha256, kind, size and the items found"
/>

## The rules this contract keeps

1. **Reading a document never authorizes paying it.** Before a confirmation each boleto is quoted on the rail and each Pix destination is resolved. What an item shows is what the rail said (beneficiary name, masked document, amount, due date), not what was read in the file.
2. **A bare Pix key never sets the amount.** The person, the integration or a CSV column states it, and `amount_source` says which: `rail_quote`, `br_code`, `stated_by_person` or `csv`.
3. **A confirmation binds to one exact list.** Every change bumps `version` and changes `list_hash`. Pay sends both back; if the list moved, the call is refused with the current list and the person confirms again.
4. **An item's identity is its content, not its position.** Re-uploading the same file into the same batch, reordering or resuming never creates another item or another payment.
5. **An uncertain result is never resent.** The item goes to `uncertain`, and the next pay call looks it up first.
6. **Mandate caps hold under concurrency.** Each item goes through the governed payment cycle; two pay calls on one batch never dispatch an item twice.

## Prerequisites

| Route | Scope |
|---|---|
| `POST /v1/payables/files` | `payables:write` |
| `POST /v1/payables/batches`, `PATCH .../items/:itemId`, `POST .../items` | `payables:write` |
| `GET /v1/payables/batches/:batchId` | `payables:read` |
| `POST /v1/payables/batches/:batchId/pay` | `payables:pay`, plus a `mandate_id` of your organization |

Use a test key (`csk_test_…`) while you follow this page: a boleto settles against the sandbox fixture (marked in the receipt) and a Pix goes to the sandbox. Keys are minted at `/dashboard/api-keys`.

<Callout type="info">
The SDK and CLI methods on this page ship in `@codespar/sdk` 0.16.19, `codespar` (Python) 0.13.0 and `@codespar/cli` 0.21.0. The same five journeys as tool calls of the `@codespar/mcp` 0.5.10 server (`codespar_payables`) are on [its page](/docs/api/sdk/providers/mcp#pay-documents-and-batches-codespar_payables). Amounts are always integers in centavos: R$ 150,00 is `15000`.
</Callout>

What each file can be:

| Kind | Accepted as | Limits |
|---|---|---|
| PDF | magic bytes, read in a sandboxed child | 10 MB, 10 pages |
| Image (PNG, JPEG, WEBP) | a boleto barcode or a Pix QR | 10 MB |
| CSV, XLSX | one payment per row | 10 MB, 500 rows |

Columns a CSV or XLSX row may carry, by header: `pix_key` (or `chave`), `boleto_line` (or `linha digitavel`), `br_code` (or `copia e cola`), `amount` in reais (or `valor`) or `amount_minor` in centavos, `description`, `reference` (or `referencia`). A sheet is the one place an amount is read from, because it is the caller's own data. A row with an explicit `reference` keeps the same identity across uploads.

## Journey 1: a boleto PDF, quoted, confirmed and paid

Upload the file, open a batch from it, read what the rail resolved, and pay the list as it stands.

<Tabs items={["API", "TypeScript", "Python", "CLI"]}>
<Tab value="API">

```bash
# 1. The file: read in the request, never kept.
curl -X POST https://api.codespar.dev/v1/payables/files \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -F "file=@conta-de-luz.pdf"
# 201 {"file_id":"pfile_…","kind":"pdf","items_found":1,"unresolved":0,"expires_at":"…"}

# 2. The batch. Idempotency-Key is required: a replay returns the same batch.
curl -X POST https://api.codespar.dev/v1/payables/batches \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Idempotency-Key: luz-2026-11" \
  -H "content-type: application/json" \
  -d '{"file_ids":["pfile_…"]}'
# 201 {"batch_id":"pbat_…","status":"ready","version":1,"list_hash":"…","items":[…]}

# 3. Pay the list you read: its version and list_hash.
curl -X POST https://api.codespar.dev/v1/payables/batches/pbat_…/pay \
  -H "Authorization: Bearer $CODESPAR_API_KEY" \
  -H "Idempotency-Key: luz-2026-11-pay" \
  -H "content-type: application/json" \
  -d '{"version":1,"list_hash":"…","mandate_id":"cm_…"}'
```

</Tab>
<Tab value="TypeScript">

```ts
import { readFile } from "node:fs/promises";
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });

const file = await cs.payables.files.upload(await readFile("conta-de-luz.pdf"), { filename: "conta-de-luz.pdf" });
const batch = await cs.payables.batches.create({ fileIds: [file.file_id] }, { idempotencyKey: "luz-2026-11" });

for (const item of batch.items) {
  // The rail's values: who receives, the masked document, the amount and where it came from.
  console.log(item.destination?.beneficiary_name, item.destination?.beneficiary_document_masked, item.amount_minor, item.amount_source);
}

const paid = await cs.payables.batches.pay(batch.batch_id, {
  version: batch.version,
  listHash: batch.list_hash,
  mandateId: "cm_example",
});
console.log(paid.status, paid.items.map((i) => i.payment?.state));
```

</Tab>
<Tab value="Python">

```python
import os

from codespar import CodeSpar

cs = CodeSpar(api_key=os.environ["CODESPAR_API_KEY"])

file = cs.payables.files.upload("conta-de-luz.pdf")
batch = cs.payables.batches.create(file_ids=[file["file_id"]], idempotency_key="luz-2026-11")

for item in batch["items"]:
    dest = item["destination"] or {}
    print(dest.get("beneficiary_name"), item["amount_minor"], item["amount_source"])

paid = cs.payables.batches.pay(
    batch["batch_id"],
    version=batch["version"],
    list_hash=batch["list_hash"],
    mandate_id="cm_example",
)
print(paid["status"], [i["payment"] and i["payment"]["state"] for i in paid["items"]])
```

</Tab>
<Tab value="CLI">

```bash
codespar payables batches create --file conta-de-luz.pdf --idempotency-key luz-2026-11
# prints the batch id, version, list_hash and each item as the rail resolved it

codespar payables batches pay pbat_… --batch-version 1 --list-hash <list_hash> --mandate cm_…
```

`pay` takes `--batch-version`, not `--version`: the CLI keeps `--version` for its own version.

</Tab>
</Tabs>

A boleto item before paying, as the API answers it:

```json
{
  "item_id": "pbl_…",
  "origin": { "file_id": "pfile_…", "page": 1, "row": null, "kind": "boleto_line" },
  "rail": "boleto",
  "state": "ready",
  "missing": [],
  "destination": {
    "kind": "boleto",
    "display": "23790.12345 … 1 9876",
    "beneficiary_name": "Energia Exemplo SA",
    "beneficiary_document_masked": "**.345.678/0001-**",
    "due_date": "2026-11-10"
  },
  "amount_minor": 15000,
  "amount_source": "rail_quote",
  "checks": [
    { "code": "carrier_check_digits", "status": "pass", "message": "…" },
    { "code": "amount_matches_rail", "status": "pass", "message": "…" },
    { "code": "due_date_state", "status": "pass", "message": "…" }
  ],
  "payment": null
}
```

After paying, `payment` carries `state` (`executed`, `refused` or `uncertain`), `attempt_id`, `receipt_id`, `end_to_end_id` and, for a refusal, `refusal.code` and `refusal.message`.

## Journey 2: a photo of a Pix QR, decoded and paid

The same upload, with an image. The QR is decoded and its CRC checked; a static code carries its own amount (`amount_source: "br_code"`), and a dynamic one is decoded by the rail's own call, never by fetching the code's URL.

<Tabs items={["API", "TypeScript", "Python", "CLI"]}>
<Tab value="API">

```bash
curl -X POST https://api.codespar.dev/v1/payables/files \
  -H "Authorization: Bearer $CODESPAR_API_KEY" -F "file=@qr-fornecedor.png"
curl -X POST https://api.codespar.dev/v1/payables/batches \
  -H "Authorization: Bearer $CODESPAR_API_KEY" -H "Idempotency-Key: qr-001" \
  -H "content-type: application/json" -d '{"file_ids":["pfile_…"]}'
```

</Tab>
<Tab value="TypeScript">

```ts
import { readFile } from "node:fs/promises";
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const file = await cs.payables.files.upload(await readFile("qr-fornecedor.png"), { filename: "qr-fornecedor.png", contentType: "image/png" });
const batch = await cs.payables.batches.create({ fileIds: [file.file_id] }, { idempotencyKey: "qr-001" });
const [item] = batch.items;
console.log(item?.destination?.kind, item?.destination?.beneficiary_name, item?.amount_minor, item?.amount_source);
```

</Tab>
<Tab value="Python">

```python
with open("qr-fornecedor.png", "rb") as fh:
    file = cs.payables.files.upload(fh, filename="qr-fornecedor.png", content_type="image/png")
batch = cs.payables.batches.create(file_ids=[file["file_id"]], idempotency_key="qr-001")
item = batch["items"][0]
print(item["destination"]["kind"], item["amount_minor"], item["amount_source"])
```

</Tab>
<Tab value="CLI">

```bash
codespar payables batches create --file qr-fornecedor.png --idempotency-key qr-001
```

</Tab>
</Tabs>

Pay it exactly as in journey 1, with the batch's `version` and `list_hash`.

## Journey 3: a Pix key in text, with the amount the person stated

No file at all: the person says "R$ 50 para a chave pagamentos@fornecedor.com.br". The key is resolved on the DICT (owner name and masked document); the amount is the person's.

<Tabs items={["API", "TypeScript", "Python", "CLI"]}>
<Tab value="API">

```bash
curl -X POST https://api.codespar.dev/v1/payables/batches \
  -H "Authorization: Bearer $CODESPAR_API_KEY" -H "Idempotency-Key: pix-texto-001" \
  -H "content-type: application/json" \
  -d '{"items":[{"pix_key":"pagamentos@fornecedor.com.br","amount_minor":5000,"description":"Frete"}]}'
```

</Tab>
<Tab value="TypeScript">

```ts
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const batch = await cs.payables.batches.create(
  { items: [{ pixKey: "pagamentos@fornecedor.com.br", amountMinor: 5000, description: "Frete" }] },
  { idempotencyKey: "pix-texto-001" },
);
// amount_source is "stated_by_person"; the owner's name comes from the DICT.
console.log(batch.items[0]?.destination?.beneficiary_name, batch.items[0]?.amount_source);
```

</Tab>
<Tab value="Python">

```python
batch = cs.payables.batches.create(
    items=[{"pix_key": "pagamentos@fornecedor.com.br", "amount_minor": 5000, "description": "Frete"}],
    idempotency_key="pix-texto-001",
)
```

</Tab>
<Tab value="CLI">

```bash
codespar payables batches create --item pix_key=pagamentos@fornecedor.com.br,amount_minor=5000 --idempotency-key pix-texto-001
```

</Tab>
</Tabs>

A Pix key sent without `amount_minor` is not refused: the item comes back `needs_complement` with `missing: ["amount"]`, and journey 4 shows how to complete it.

## Journey 4: one document, several charges, one completed in the review

A PDF with three charges (two boletos and a Pix key with no amount) becomes three items. The batch is `reviewing` until the third one has an amount.

<Tabs items={["API", "TypeScript", "Python", "CLI"]}>
<Tab value="API">

```bash
# The item that misses its amount: state needs_complement, missing ["amount"].
curl -X PATCH https://api.codespar.dev/v1/payables/batches/pbat_…/items/pbl_… \
  -H "Authorization: Bearer $CODESPAR_API_KEY" -H "content-type: application/json" \
  -d '{"expected_version":1,"amount_minor":32000}'
# 200: the item re-resolved, every other item as it was, version 2 and a new list_hash.
```

</Tab>
<Tab value="TypeScript">

```ts
import { readFile } from "node:fs/promises";
import { CodeSpar, type PayableBatch } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const file = await cs.payables.files.upload(await readFile("fechamento-outubro.pdf"), { filename: "fechamento-outubro.pdf" });
let batch: PayableBatch = await cs.payables.batches.create({ fileIds: [file.file_id] }, { idempotencyKey: "fechamento-10" });

for (const item of batch.items) {
  if (item.missing.includes("amount")) {
    batch = await cs.payables.batches.updateItem(batch.batch_id, item.item_id, { expectedVersion: batch.version, amountMinor: 32000 });
  }
}
// To leave one out instead: updateItem(batchId, itemId, { expectedVersion, excluded: true }).
const paid = await cs.payables.batches.pay(batch.batch_id, { version: batch.version, listHash: batch.list_hash, mandateId: "cm_example" });
```

</Tab>
<Tab value="Python">

```python
batch = cs.payables.batches.create(file_ids=[file["file_id"]], idempotency_key="fechamento-10")
for item in batch["items"]:
    if "amount" in item["missing"]:
        batch = cs.payables.batches.update_item(
            batch["batch_id"], item["item_id"], expected_version=batch["version"], amount_minor=32000
        )
```

</Tab>
<Tab value="CLI">

```bash
codespar payables batches get pbat_…
codespar payables batches update-item pbat_… pbl_… --expected-version 1 --amount 32000
codespar payables batches update-item pbat_… pbl_… --expected-version 2 --exclude
```

</Tab>
</Tabs>

To add a charge the document did not have, `POST /v1/payables/batches/:batchId/items` with `expected_version` and `items` (SDK `addItems`, Python `add_items`).

## Journey 5: a CSV batch, one line over the cap

A spreadsheet of 12 supplier payments, one of which exceeds what the mandate still allows. The other 11 are paid; the refused one keeps its code, and nothing about it stops the rest.

```csv
pix_key,amount,description,reference
financeiro@fornecedor-a.com.br,1250.00,NF 1201,forn-a-1201
11222333000181,980.50,NF 88,forn-b-88
…
```

<Tabs items={["API", "TypeScript", "Python", "CLI"]}>
<Tab value="API">

```bash
curl -X POST https://api.codespar.dev/v1/payables/files \
  -H "Authorization: Bearer $CODESPAR_API_KEY" -F "file=@fornecedores-outubro.csv"
# then create the batch and pay it as in journey 1
```

</Tab>
<Tab value="TypeScript">

```ts
import { readFile } from "node:fs/promises";
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
const file = await cs.payables.files.upload(await readFile("fornecedores-outubro.csv"), { filename: "fornecedores-outubro.csv", contentType: "text/csv" });
const batch = await cs.payables.batches.create({ fileIds: [file.file_id] }, { idempotencyKey: "fornecedores-10" });
const paid = await cs.payables.batches.pay(batch.batch_id, { version: batch.version, listHash: batch.list_hash, mandateId: "cm_example" });

console.log(paid.status); // "partially_completed"
for (const item of paid.items) {
  if (item.payment?.state === "refused") console.log(item.item_id, item.payment.refusal?.code); // e.g. total_cap_exceeded
}
```

</Tab>
<Tab value="Python">

```python
file = cs.payables.files.upload("fornecedores-outubro.csv")
batch = cs.payables.batches.create(file_ids=[file["file_id"]], idempotency_key="fornecedores-10")
paid = cs.payables.batches.pay(
    batch["batch_id"], version=batch["version"], list_hash=batch["list_hash"], mandate_id="cm_example"
)
refused = [i for i in paid["items"] if i["payment"] and i["payment"]["state"] == "refused"]
```

</Tab>
<Tab value="CLI">

```bash
codespar payables batches create --file fornecedores-outubro.csv --idempotency-key fornecedores-10
codespar payables batches pay pbat_… --batch-version 1 --list-hash <list_hash> --mandate cm_…
```

</Tab>
</Tabs>

A line over the mandate's total cap is `refused` with `total_cap_exceeded`. A Pix line over the mandate's per-transaction cap is routed to a person instead: the item stays `dispatching` with no `payment` and waits in Aprovações, as every Pix call does.

## Confirming, resuming and repeating

**A change after confirming needs a new confirmation.** Once a batch is confirmed, a correction answers 409 `payable_batch_locked`. Before that, a pay with a `version` or `list_hash` that is not the current list answers 409 `payable_batch_version_mismatch` with the current batch in `error.details.batch`: show it and confirm again.

```ts
import { CodeSpar, payableBatchFromError } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY });
try {
  await cs.payables.batches.pay("pbat_example", { version: 3, listHash: "a".repeat(64), mandateId: "cm_example" });
} catch (err) {
  const current = payableBatchFromError(err);
  if (current) console.log("the list changed; confirm version", current.version, current.list_hash);
}
```

In Python, `payable_batch_from_error(err)` returns the same batch.

**Large batches continue on the next call.** One pay call executes for about 20 seconds. Past that it answers `status: "executing"`, with the items it did not reach still `ready`, and nothing runs on the server until the next call. Repeat the same call (same `version`, same `list_hash`) to continue: executed items come back as they are, an `uncertain` one is looked up first and never resent, and only items never dispatched are sent. The SDKs, the CLI and the `codespar_payables` tool repeat for you while that holds.

**A repeat never pays twice.** The payment attempt is derived from the item and its quote, so the same item with the same quote is the same attempt, the same hold and the same receipt. Two concurrent pay calls on one batch claim each item once.

## In the Shop chat

In the dashboard's Shop, attach files to a message (button, drag and drop, or paste: PDF, PNG, JPEG, WEBP, CSV, XLSX, up to 20 files of 10 MB each). Each file shows what was read in it before you send. The message carries the uploaded `file_ids` as `attachments`; the assistant is told only the batch id, the item count, what needs completing and the masked destinations and amounts, never the document. You can also state payments in text and the assistant adds them to the same batch. The batch card shows each item as the rail resolved it, asks for what is missing, and pays with one button for the exact list on screen. The assistant never confirms or pays.

## Errors you will meet

| Status | Code | What to do |
|---|---|---|
| 413 | `payable_file_too_large` | The file is over 10 MB; nothing was read or kept |
| 415 | `payable_file_type_unsupported` | Send PDF, PNG, JPEG, WEBP, CSV or XLSX |
| 422 | `payable_file_unreadable` | `details.reason` says why: `too_many_pages`, `encrypted`, `active_content`, `malformed`, `resource_limit`, `webp_unsupported`, `sheet_unreadable` |
| 422 | `payable_file_rows_exceeded` | More than 500 rows; split the sheet |
| 503 | `payable_file_inspection_busy` | Retry after the `retry-after` seconds |
| 400 | `payable_batch_idempotency_key_required` | Send `Idempotency-Key` on create and pay |
| 409 | `payable_batch_version_conflict` | A correction on a stale version; `details.batch` is current |
| 409 | `payable_batch_version_mismatch` | Pay on a list that changed; confirm the current one |
| 409 | `payable_batch_locked` | The batch is confirmed and no longer changes |
| 422 | `payable_batch_not_ready` | `details.items` lists what to complete, correct or exclude |
| 422 | `payable_batch_mandate_required` | An API key pays under a `mandate_id` |

Item refusals at execution carry the governed cycle's own codes (`total_cap_exceeded`, insufficient funds and others) or `payable_quote_diverged` (the rail's amount changed since the review) and `payable_rail_unavailable`.
