Sending transactions in batches

Submit up to 10,000 pre-calculated sales, refunds or transfers in a single request, with per-transaction results and safe retries.

If you send Brinta a high volume of transactions, calling POST /sales/ once per record gets expensive fast: every transaction costs a TLS handshake, a token validation, and a full round trip. A month-end load of 50,000 sales spends most of its wall clock waiting on the network.

The Batch API lets you submit up to 10,000 transactions in one request and track the whole load as a single object.

📘

Loading accounting entries instead?

Accounting entries have their own submission endpoint, POST /accounting-entry-batches/, because their payload has nothing in common with a transaction. Everything else on this page (status, results, retries, errors, limits) applies identically. See Sending accounting entries in batches.

📘

Batches store, they do not calculate

Transactions submitted through a batch always run with use_tax_engine: false. You supply the taxes on each line item and Brinta validates and stores them. If you need Brinta to calculate taxes, use POST /sales/ one transaction at a time. calculation is not a valid transaction_type for a batch.

Sending a batch

Each element of transactions is exactly the body you would send to POST /sales/, POST /refunds/ or POST /transfers/. If you already integrated the single-resource endpoints, you can reuse your serializer as-is.

curl -X POST https://api.brinta.com/transaction-batches/ \
  -H "Authorization: Bearer $BRINTA_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "transaction_type": "sale",
    "options": { "on_duplicate": "skip" },
    "transactions": [
      {
        "transaction_external_id": "SALE-AR-001",
        "currency": "ARS",
        "buyer": { "company_external_id": "CLI-99812" },
        "items": [
          {
            "name": "Logistics",
            "amount": 5000,
            "quantity": 1,
            "taxes": [
              { "name": "IVA", "type": "IVA", "rate": 0.21, "amount": 1050,
                "level": "country", "location": "AR", "adds_to_final_amount": true }
            ]
          }
        ]
      }
    ]
  }'

Three rules differ from the single-resource endpoints:

  • transaction_external_id is required and must be unique within the batch. It is how results are correlated back to your records, and how retries are deduplicated.
  • The per-transaction type field is optional. It is inherited from transaction_type. If you do send it, it must match, otherwise the whole batch is rejected with a 422.
  • Refunds can reference the original sale by your own identifier. See Refunds below.

Every batch is homogeneous: one transaction_type per request. The value determines which endpoint each entry is routed to:

transaction_typeProcessed as
salePOST /sales/
refund, chargeback, adjustmentPOST /refunds/
transfer, transfer reversal, to settle, settlement, payment from invoicePOST /transfers/

calculation is not accepted. Batches never run the tax engine.

Refunds

Refunds work like any other type, with one addition that matters at batch scale.

POST /refunds/ identifies the sale being refunded with original_transaction_id, which is Brinta's ID. That is fine one at a time, but in a batch you would have to resolve thousands of Brinta IDs before you could even build the request, which defeats the point. So in a batch you can reference the original sale by your own identifier instead:

{
  "transaction_external_id": "REF-AR-0087",
  "original_transaction_external_id": "SALE-AR-3412",
  "status": "invoiced",
  "final_amount": 12100,
  "invoice_number": "NC-0001-00000045",
  "invoice_date": "2026-08-15"
}

Send exactly one of original_transaction_id or original_transaction_external_id. If the reference does not resolve, that refund comes back as failed with error_reason: "Original Transaction Not Found".

Refunds can be total (omit items and the amounts), partial by value (amount or final_amount), or per line (list the items you are refunding).

📘

The sale has to exist first

A batch is homogeneous, so a refund batch can never contain the sale it refers to. Load the sales, wait for that batch to finish, then load the refunds.

Getting the result

POST /transaction-batches/ always returns 202 Accepted, no matter how many transactions you send. There is one response shape and one integration path whether the batch has 3 rows or 10,000.

{
  "batch_id": "bat_01K2MA3D7RZ",
  "kind": "transactions",
  "status": "queued",
  "transaction_type": "sale",
  "dry_run": false,
  "created_at": "2026-08-17T14:31:00Z",
  "summary": { "total": 4200, "succeeded": 0, "failed": 0, "skipped": 0, "pending": 4200 },
  "links": {
    "self": "https://api.brinta.com/batches/bat_01K2MA3D7RZ",
    "results": "https://api.brinta.com/batches/bat_01K2MA3D7RZ/results"
  }
}

Structural problems are still reported synchronously. An invalid transaction_type, an empty transactions array, a missing or duplicated transaction_external_id, or a type that contradicts the envelope all come back as a 422 before the batch is ever queued. You never have to poll to find out your payload was malformed.

To retrieve the outcome, you have three options.

Long-poll the batch. GET /batches/{batch_id}?wait=30 holds the connection until the batch reaches a terminal state or the timeout elapses, up to 60 seconds. A small batch finishes in milliseconds, so in practice this replaces an entire polling loop with a single call.

curl "https://api.brinta.com/batches/bat_01K2MA3D7RZ?wait=30" \
  -H "Authorization: Bearer $BRINTA_TOKEN"

Poll without waiting. GET /batches/{batch_id} returns immediately with current progress counters. Useful for a progress bar.

Use a webhook. Set options.callback_url and Brinta will POST a batch.completed event when the batch finishes, signed with HMAC-SHA256 in the Brinta-Signature header. The event carries the summary and a link to the results, not the results themselves.

⚠️

A 202 does not mean your transactions were stored

It means the batch was accepted. Individual transactions can still fail. Always read summary.failed before considering a load complete.

Reading per-transaction results

curl "https://api.brinta.com/batches/bat_01K2MA3D7RZ/results?status=failed" \
  -H "Authorization: Bearer $BRINTA_TOKEN"

Filtering by status=failed is the common case: you rarely need the thousands of rows that worked.

{
  "batch_id": "bat_01K2MA3D7RZ",
  "results": [
    {
      "index": 1,
      "external_id": "SALE-AR-002",
      "status": "failed",
      "http_status": 422,
      "errors": [
        {
          "error_reason": "Incorrect Buyer Tax Registration",
          "error_detail": "CUIT 30-1234-9 does not match the format XX-XXXXXXXX-X.",
          "error_field": "buyer.company.tax_registrations[0].number"
        }
      ]
    }
  ],
  "pagination": { "next_cursor": "eyJpIjo0MDB9", "has_more": true }
}

Errors use the same envelope as Invoice Errors, with one addition: error_field tells you exactly which value to fix. That matters at batch scale, where knowing that something is wrong with the amounts on row 3,412 of 5,000 is not enough to act on.

error_field paths are relative to the transaction, not the batch. items[0].amount refers to the first line item of that transaction.

Successful results carry the assigned id rather than an echo of what you submitted. To read the stored record, call the canonical endpoint for that type: GET /sales/{id}, GET /refunds/{id} or GET /transfers/{id}.

Results are retained for 30 days. After that the batch moves to expired and per-transaction detail is purged. The transactions themselves are never affected.

The four outcomes

StatusMeaningExists in Brinta?
succeededCreated by this batchYes, new
skippedAlready existed, nothing doneYes, pre-existing
failedRejected by validation, or failed to persistNo
not_processedThe batch aborted before reaching itNo

Why skipped exists

Say you submit 5,000 sales and your process dies at row 2,400. You have no way of knowing how many made it.

With skipped, the correct response is simply to resend the entire batch. The 2,400 that already landed come back as skipped, and the remaining 2,600 come back as succeeded. You do not have to reconcile anything first, you do not have to track a progress cursor, and you do not duplicate a single sale.

"summary": { "total": 5000, "succeeded": 2600, "failed": 0, "skipped": 2400 }

In v1 there is exactly one cause: a transaction_external_id that already exists for your company, with options.on_duplicate set to skip (the default). Set it to error if you would rather be told explicitly, in which case those transactions come back as failed with error_reason: "Duplicate External Id".

Retrying safely

There are two independent layers of protection, covering two different time windows.

Idempotency-Key protects the immediate retry. It is required on every batch. Resend the same key with the same body within 24 hours and you get the original batch_id back with nothing re-queued. A network timeout on your side stops being a duplicated load. Resend the same key with a different body and you get a 409, which catches the bug of reusing a key across two different loads.

transaction_external_id protects the late or partial retry: a different key, more than 24 hours later, or a batch you rebuilt with different contents. There, the batch does reprocess, and per-transaction deduplication is what keeps you from double-writing.

This is why transaction_external_id is required in batches even though it is optional on POST /sales/. Without it, neither layer can protect you.

Validating before you commit

Set options.dry_run: true to run full validation without storing anything. Successful transactions come back as succeeded with no transaction_id, and the batch echoes dry_run: true so there is no ambiguity about whether anything was written.

This makes onboarding a new data source iterative instead of destructive: run the dry batch, fix what ?status=failed reports, repeat, then flip the flag.

Handling failures

By default (on_error: "continue") every transaction is processed independently. One bad tax ID on row 3,412 does not block the other 4,999.

Set on_error: "abort" to stop at the first failure instead. Transactions already processed remain stored; the rest come back as not_processed. There is no rollback. This is for when you would rather stop and investigate than keep ingesting questionable data, not a substitute for an all-or-nothing transaction.

Limits

LimitValue
Transactions per batch10,000
Request body size10 MB
Concurrent batches in processing, per company3
POST /batches/ requests10 per minute
Transactions200,000 per hour, per company
wait on GET /batches/{id}60 seconds

Standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers are returned on every response.

📘

Transactions within a batch are processed in parallel and completion order is not guaranteed. If you depend on sequential ordering, such as correlative invoice numbering, use the single-resource endpoints instead.


Did this page help you?