Upload a batch
BuildingIn development for the v1 API. The contract is set; the route is not served yet.
For high volumes, up to 100 million rows per file. Every row is checked for format and catalogs while the batch validates, so errors lists what to fix before anything is stamped. The receiver is not verified against the SAT: the file is expected to contain data already checked with fiscal validations, and a document the stamping authority refuses ends failed in the report with its reason. Each party is identified by its RFC or its heru id, and with neither the row is invalid. Create the batch with its type and, optionally, defaults for the columns you would repeat on every row; PUT the CSV, plain or gzip, to upload_url within 24 hours; wait for ready and process it with POST /batches/{id}/process. Each type has its own columns, listed in the batches guide. The same documents can go through the JSON endpoints one by one. A batch lives 20 days at most from this call: one still unfinished at that point is cancelled as POST /batches/{id}/cancel would.
Needs the batches:write scope, plus the scope that creating each document needs one by one: invoices:write or retention_certificates:write.
oauth2 batches:writeAuthorizationBearer <token>OAuth 2.0 client credentials. Exchange your client_id and client_secret at /oauth/token of the environment for a bearer token valid for one hour. The playground of every operation asks for your own client and keeps it in your browser tab only.
Scope: batches:write
Idempotency-Key*stringUnique per operation. Retrying with the same key returns the original result for 24 hours.
length <= 255application/json- body
type*stringWhat the rows of the CSV are: invoice or retention_certificate. Each type has its own columns, listed in the batches guide.
"invoice""retention_certificate"defaults?Values for the columns of this type that the file leaves out or leaves empty, as {column: value} with string values. Any document or line column is valid. For each field the row wins, then defaults, then what the JSON endpoint assumes for your company. An unknown column or an invalid value answers 422 VALIDATION_FAILED and nothing is created.
Created
application/json- response
id?string^bat_[0-7][0-9a-hjkmnp-tv-z]{25}$type?stringWhat the rows of the CSV are: invoice or retention_certificate. Each type has its own columns, listed in the batches guide.
"invoice""retention_certificate"status?stringawaiting_upload until the whole file arrives at upload_url; validating while heru checks every row; ready when at least one document is valid; done when no document is invalid and every document is a duplicate, so nothing is stamped and the batch closes with its report and fires batch.done, without going through POST /batches/{id}/process; invalid when no document is valid and at least one failed validation, the file itself has an error or the upload window ended; processing after POST /batches/{id}/process; paused after POST /batches/{id}/pause; cancelling after POST /batches/{id}/cancel, or when the batch reaches 20 days unfinished, while heru stops what is pending and writes the report, then cancelled; done when every valid document has its outcome. invalid, cancelled and done are final.
"awaiting_upload""validating""invalid""ready""processing""paused""cancelling""cancelled""done"created_at?stringWhen the batch was created with POST /batches. The 20 days of the batch's life count from here: see processing pace.
date-timedefaults?The defaults of the request, normalized: 16% comes back as 0.16.
upload_url?|nullupload_expires_at?|nullupload_max_bytes?integerThe largest file upload_url takes, in bytes, compressed or not: 34359738368 (32 GiB).
int64total_rows?integerData rows in the file. Final once heru has read the whole file.
validated_rows?integerRows checked so far. Compare it with total_rows while the batch is validating.
valid_rows?integerRows that passed their own checks. A valid row can belong to an invalid document, which is not processed: progress is counted in documents.
invalid_rows?integerRows with at least one problem.
total_documents?integerDocuments in the file: rows that share external_id are one document. Final once heru has read the whole file.
valid_documents?integerDocuments with every row valid and not stamped before. Only these are processed. It does not change after ready.
invalid_documents?integerDocuments with at least one invalid row. None of their rows is processed.
duplicates?integerDocuments whose external_id was already stamped. They are not stamped again and the report gives their id and uuid. A document already stamped wins over its own validation errors: it counts here, not in invalid_documents.
processed_documents?integerValid documents with an outcome so far: succeeded + failed + cancelled + pending_review. Compare it with valid_documents while the batch is processing.
in_flight_documents?integerDocuments already prepared and on their way to the PAC. They keep moving while the batch is paused, so this says how much is left before the counters stop.
succeeded?integerDocuments stamped.
failed?integerValid documents that failed when processed, e.g. refused by the stamping authority. The reason is in the report. A failed document can go again in a new batch.
cancelled?integerValid documents never processed because the batch was cancelled.
pending_review?integerDocuments with no outcome when the batch closed. They are not lost: if one gets stamped later, its webhook arrives.
estimated_completion_at?|nullfits_lifetime?|nullreceiver_overrides?integerDocuments whose receiver data in the file differs from the data registered in heru, which is what gets stamped. Estimated during validation and final when the batch closes; the report names the columns.
errors?array<>The first 100 problems, in row order. error_count has the total and errors_url all of them. Final when the batch leaves validating.
error_count?integerTotal problems found, which can be more than errors shows.
errors_url?|nullreport_url?|nullcurl -X POST 'https://api.dev.b2b.heru.app/v1/batches' \ -H 'Idempotency-Key: try-it-0001' \ -H 'Content-Type: application/json' \ -d '{ "type": "invoice", "defaults": { "payment_form": "03", "quantity": "1", "unit_code": "E48", "tax_object": "02", "vat_rate": "16%" }}'{ "id": "bat_01m4ra8wpbev7sf851m6pn6gm7", "type": "invoice", "status": "awaiting_upload", "created_at": "2026-10-28T15:04:05Z", "defaults": { "payment_form": "03", "quantity": "1", "unit_code": "E48", "tax_object": "02", "vat_rate": "0.16" }, "upload_url": "https://upload.heru.app/batches/bat_01m4ra8wpbev7sf851m6pn6gm7?upload_id=ADPycdtH7Xq2", "upload_expires_at": "2026-11-01T15:20:11Z", "upload_max_bytes": 34359738368, "total_rows": 0, "validated_rows": 0, "valid_rows": 0, "invalid_rows": 0, "total_documents": 0, "valid_documents": 0, "invalid_documents": 0, "duplicates": 0, "processed_documents": 0, "in_flight_documents": 0, "succeeded": 0, "failed": 0, "cancelled": 0, "pending_review": 0, "estimated_completion_at": null, "fits_lifetime": null, "receiver_overrides": 0, "errors": [], "error_count": 0, "errors_url": null, "report_url": null}Get a download linkGET
Returns a signed link to the stamped certificate on `download.heru.app`, valid for 15 minutes. `format=xml` is the CFDI as the SAT stored it, the document with fiscal validity; `format=pdf` is a printable version with the SAT seal and the QR to verify it. The files are never at a public address: request a new link each time someone needs them. Needs the `retention_certificates:download` scope.
Process a batchPOST
Processes every valid document: stamps the invoices or retention certificates. Answers `202`; the batch goes `processing` and `batch.done` fires when it is `done`. heru sets the pace, with a quota per company and a cap shared by everyone: see [processing pace](/guides/batches#processing-pace). Repeating it while the batch is `processing` or `paused` answers `202` with the same batch. Any other state answers `409 BATCH_NOT_READY`: `awaiting_upload` and `validating` can still reach `ready`, but `invalid`, `cancelling`, `cancelled` and `done` never will, so upload the file to a new batch instead of retrying. **A batch that is known not to finish does not start.** When `fits_lifetime` is `false`, it answers `409 BATCH_EXCEEDS_CAPACITY` and stamps nothing. Split the file into several batches, or ask your account team for more capacity. The body is optional: send `{"allow_partial": true}` to start anyway. heru stamps what fits and, 20 days after `created_at`, cancels what is still pending, with the semantics of [cancel](/guides/batches#pause-resume-and-cancel). `fits_lifetime` is an estimate, so a batch that fits can still be cancelled at 20 days if the pace drops. While emission is paused for maintenance, a `ready` batch may report `fits_lifetime` `false` and this operation answer `409 BATCH_EXCEEDS_CAPACITY` until it resumes; `allow_partial` still starts it. Needs the `batches:write` scope, plus the scope of the batch `type`.