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.
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.
The rules this contract keeps
- 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.
- A bare Pix key never sets the amount. The person, the integration or a CSV column states it, and
amount_sourcesays which:rail_quote,br_code,stated_by_personorcsv. - A confirmation binds to one exact list. Every change bumps
versionand changeslist_hash. Pay sends both back; if the list moved, the call is refused with the current list and the person confirms again. - 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.
- An uncertain result is never resent. The item goes to
uncertain, and the next pay call looks it up first. - 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.
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:
| Kind | Accepted as | Limits |
|---|---|---|
| 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.
# 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-001Pay 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-001A 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 --excludeTo 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 1import { 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
| 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.
Marketplace Payout
Split a marketplace transaction into platform fee + seller payout. One agent, two settled transactions, full audit trail.
Agent with a Wallet
Walkthrough of Programmable Wallets: create a wallet, bind an Asaas funding source, fund via sandbox Pix, and see what the mandate-gated execute path does today. ~15 minutes.