Skip to main content
Cookbooks

Pay Documents and Batches

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.

6 min read
View MarkdownEdit on GitHub
TIME
~25 min
STACK
@codespar/sdkcodespar (Python)@codespar/cli@codespar/mcpcurl
RAILS
boletoPix

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.

↻ FROM DOCUMENT TO PAID LIST
Read locally, resolve on the rail, confirm the exact list, pay each item through the governed cycle
1 · FILE
Upload, read in the request
POST /v1/payables/files
→
2 · BATCH
Items resolved on the rail
POST /v1/payables/batches
→
3 · PAY
Confirm version + list_hash
POST /v1/payables/batches/:batchId/pay
STEP 1
boleto
quoted on the rail: amount, beneficiary, masked document, due date
→
STEP 2
Pix key
owner name and masked document from the DICT; the amount comes from the person
→
STEP 3
Pix QR / copia e cola
decoded with its CRC; a dynamic code through the rail's own decode
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

RouteScope
POST /v1/payables/filespayables:write
POST /v1/payables/batches, PATCH .../items/:itemId, POST .../itemspayables:write
GET /v1/payables/batches/:batchIdpayables:read
POST /v1/payables/batches/:batchId/paypayables: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.

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. Amounts are always integers in centavos: R$ 150,00 is 15000.

What each file can be:

KindAccepted asLimits
PDFmagic bytes, read in a sandboxed child10 MB, 10 pages
Image (PNG, JPEG, WEBP)a boleto barcode or a Pix QR10 MB
CSV, XLSXone payment per row10 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.

# 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_…"}'
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));
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"]])
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.

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

{
  "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.

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_…"]}'
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);
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"])
codespar payables batches create --file qr-fornecedor.png --idempotency-key qr-001

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.

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"}]}'
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);
batch = cs.payables.batches.create(
    items=[{"pix_key": "pagamentos@fornecedor.com.br", "amount_minor": 5000, "description": "Frete"}],
    idempotency_key="pix-texto-001",
)
codespar payables batches create --item pix_key=pagamentos@fornecedor.com.br,amount_minor=5000 --idempotency-key pix-texto-001

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.

# 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.
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" });
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
        )
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

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.

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
…
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
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
}
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"]
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_…

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.

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

StatusCodeWhat to do
413payable_file_too_largeThe file is over 10 MB; nothing was read or kept
415payable_file_type_unsupportedSend PDF, PNG, JPEG, WEBP, CSV or XLSX
422payable_file_unreadabledetails.reason says why: too_many_pages, encrypted, active_content, malformed, resource_limit, webp_unsupported, sheet_unreadable
422payable_file_rows_exceededMore than 500 rows; split the sheet
503payable_file_inspection_busyRetry after the retry-after seconds
400payable_batch_idempotency_key_requiredSend Idempotency-Key on create and pay
409payable_batch_version_conflictA correction on a stale version; details.batch is current
409payable_batch_version_mismatchPay on a list that changed; confirm the current one
409payable_batch_lockedThe batch is confirmed and no longer changes
422payable_batch_not_readydetails.items lists what to complete, correct or exclude
422payable_batch_mandate_requiredAn 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.

Pay Documents and Batches | CodeSpar