Import spreadsheet
Issue invoices in bulk from a CSV or Excel spreadsheet: upload, review, issue and follow the counters, by API or MCP.
Issue hundreds of invoices from one spreadsheet. You upload a .csv or .xlsx, Corvo reads and checks every row in the background, you review the problems if you want, and then you issue everything. The same flow is available in the dashboard (Invoices, Import spreadsheet) and to assistants through MCP.
How it works
POST /v1/importsuploads the file and answers right away with the import id.- Poll
GET /v1/imports/:iduntilstatusisready. Each row is checked on its own: a bad row never stops the others. - Optional review:
GET /v1/imports/:id/rows?status=invalidshows the problems,PATCH /v1/imports/:id/rows/:linefixes one,GET /v1/imports/:id/errors.csvdownloads only the errors. POST /v1/imports/:id/emitissues the valid rows. Follow the counters untilstatusisdone.
The spreadsheet
The first line is the header. Names are matched without caring about case or accents, in Portuguese or English (valor or amount). CSV may use ; or , and UTF-8, UTF-16 or Windows encodings. Amounts accept 1.234,56, 1234.56 and R$ 1.234,56; dates accept dd/mm/aaaa (also 7/11/2026, 07-11-2026 and 07.11.2026) and aaaa-mm-dd. Columns that match no name below are ignored and listed in unknown_columns of the import. Formulas are never run: only the stored value is read.
| Column | Required | What it is |
|---|---|---|
cnpj_prestador | if no company is chosen | CNPJ of the company that issues the row. |
tomador_documento | no | Customer CPF or CNPJ, with or without punctuation. Leading zeros lost by Excel are restored. |
tomador_nome | no | Customer name. |
tomador_email | no | Customer e-mail, to send the invoice. |
descricao | yes | Service description. |
valor | yes | Amount in BRL, up to 2 decimals. |
codigo_servico | no | National service code (6 digits). Empty uses the company default. |
data_competencia | no | Competence date. Empty uses today (America/Sao_Paulo). |
pedido | no | Purchase order or service order number printed on the invoice (up to 60 characters). |
observacoes | no | Free text printed on the invoice as complementary information (up to 2,000 characters). |
tomador_nif, tomador_sem_nif | no | Customer abroad: its tax id, or the reason it has none (dispensado or nao exigido). Used instead of tomador_documento. |
tomador_pais, tomador_codigo_postal, tomador_cidade, tomador_regiao, tomador_logradouro, tomador_numero, tomador_bairro, tomador_complemento | for a customer abroad | Address abroad. The country is the two-letter code (for example US); all are required except the complement. These columns are not in the basic template. |
Rules
| Rule | Detail |
|---|---|
| Limits | Up to 2,000 rows and 10 MB per file; .csv or .xlsx only. More rows fail the import with ERROR_IMPORT_TOO_MANY_ROWS. |
| Company | From the cnpj_prestador column or from companyId. A row with no company is invalid (ERROR_IMPORT_COMPANY_REQUIRED); a CNPJ different from the chosen company is invalid (ERROR_IMPORT_COMPANY_MISMATCH). |
| Environment | An import belongs to the environment of the key that created it: a test key only sees test imports, a live key only live ones. |
| Idempotency | Each line has its own key (import:<id>:<line>), so repeating emit or retrying a job never issues a line twice. |
| Not supported | SCHEDULE and RECURRING are not accepted inside a spreadsheet. |
| Permissions | Reading (GET) needs invoices_view; upload, fix, emit and discard need invoices_issue. |
Status and counters
| status | Meaning |
|---|---|
processing | The file is being read and every row checked. |
ready | Read. You can review, fix and emit. |
emitting | Issuing. Some invoices may still be waiting for the city. |
done | Nothing is left in progress. Rows awaiting credits can still be issued again. |
failed | The file could not be read (empty, too many rows or not a real spreadsheet), or the chosen company no longer exists (ERROR_IMPORT_COMPANY_NOT_FOUND); see error_code. |
counters: total, valid, invalid, accepted (saved and queued for issuing), authorized (the city already authorized), awaiting_credits (turned away for lack of credits: **not an error**), failed (invalid rows plus invoices the city refused), pending (still waiting or in progress), valid_amount and awaiting_amount in BRL. While issuing, authorized_amount, pending_amount and failed_amount show in BRL how much is already authorized, still in progress and refused (invalid rows never count, since they never went out).
/v1/imports202 AcceptedUploads a spreadsheet (.csv or .xlsx, up to 2,000 rows and 10 MB) as multipart/form-data. Reading is **asynchronous**: the answer is the import to follow, with status: "processing". If the very same file was uploaded before for this company and environment, duplicate_of carries the earlier import (a warning, not a block).
Form fields
| Field | Type | Required | Description |
|---|---|---|---|
| file | file | ✓ | The .csv or .xlsx spreadsheet. |
| companyId | string (uuid) | Company that issues the rows without a CNPJ. A row with another company's CNPJ is rejected, so the wrong company never issues. |
curl -X POST "$BASE_URL/imports" \
-H "X-API-Key: $CORVO_API_KEY" \
-F "file=@notas-novembro.csv" \
-F "companyId=5b0e7c5a-5d0b-4f45-8d6c-3a2a1e9d7f20"Response — 202 Accepted: the import to follow
{ "data": { "id": "576905ae-1707-4848-9ed6-5a6e51800b74", "status": "processing", "duplicate_of": null } }/v1/imports200 OKLists the recent imports of the environment, newest first, with their counters (at most 20). Useful to find an import again, for example one waiting for credits.
Query
| Field | Type | Required | Description |
|---|---|---|---|
| limit | integer | 1 to 20, default 10. |
curl "$BASE_URL/imports?limit=10" \
-H "X-API-Key: $CORVO_API_KEY"Response — 200 OK: a list of imports
{ "data": [ /* imports, like GET /v1/imports/:id */ ] }/v1/imports/:id200 OKStatus and counters of one import. Poll it while status is processing or emitting. While reading, rows_expected is how many rows the file has and counters.total how many are already checked.
curl "$BASE_URL/imports/576905ae-1707-4848-9ed6-5a6e51800b74" \
-H "X-API-Key: $CORVO_API_KEY"Response — 200 OK: the import with its counters
{
"data": {
"id": "576905ae-1707-4848-9ed6-5a6e51800b74",
"status": "ready",
"file_name": "notas-novembro.csv",
"company_id": null,
"error_code": null,
"rows_expected": 300,
"unknown_columns": [],
"emit_started_at": null,
"emit_finished_at": null,
"created_at": "2026-11-07T13:05:21.000Z",
"counters": {
"total": 300,
"valid": 297,
"invalid": 3,
"accepted": 0,
"authorized": 0,
"awaiting_credits": 0,
"valid_amount": 718602.19,
"awaiting_amount": 0,
"failed": 3,
"pending": 297,
"authorized_amount": 0,
"pending_amount": 718602.19,
"failed_amount": 0
}
}
}/v1/imports/:id/rows200 OKThe rows of an import, **one page at a time** (never the whole sheet). Filter by status to list only the rows with problems (invalid) and read the reason in error_code and error_message.
Query
| Field | Type | Required | Description |
|---|---|---|---|
| status | string | valid, invalid, queued, accepted or rejected. | |
| limit | integer | 1 to 200, default 50; more answers 400. | |
| cursor | string | nextCursor of the previous page. |
curl "$BASE_URL/imports/576905ae-1707-4848-9ed6-5a6e51800b74/rows?status=invalid&limit=50" \
-H "X-API-Key: $CORVO_API_KEY"Response — 200 OK: a page of rows
{ "data": [
{
"line": 42,
"data": {
"amount": "abc",
"competenceDate": "2026-11-07",
"companyTaxId": "61469487000140",
"customer": { "name": "Maria da Silva", "taxId": "52998224725" },
"service": { "description": "Consultoria mensal" }
},
"status": "invalid",
"error_code": "ERROR_VALIDATION",
"error_message": "Request validation failed",
"invoice_id": null
}
], "pagination": { "limit": 50, "hasMore": false, "nextCursor": null } }/v1/imports/:id/rows/:line200 OKCorrects one row and checks it again, only while the import is ready. Send the changed fields in item. customer and service are merged field by field, so editing the e-mail keeps the name. companyId and companyTaxId cannot be changed. The answer is the row, valid or still invalid with the reason. A row already queued for issuing answers 409 with ERROR_IMPORT_STATE.
| Field | Type | Required | Description |
|---|---|---|---|
| item | object | ✓ | Invoice fields to change: amount, competenceDate, customer { name, taxId, email }, service { description, code }. |
curl -X PATCH "$BASE_URL/imports/576905ae-1707-4848-9ed6-5a6e51800b74/rows/42" \
-H "X-API-Key: $CORVO_API_KEY" \
-H 'Content-Type: application/json' \
-d '{ "item": { "amount": 1500.5 } }'Response — 200 OK: the row, checked again
{ "data": {
"line": 42,
"data": {
"amount": 1500.5,
"competenceDate": "2026-11-07",
"companyTaxId": "61469487000140",
"customer": { "name": "Maria da Silva", "taxId": "52998224725" },
"service": { "description": "Consultoria mensal" }
},
"status": "valid",
"error_code": null,
"error_message": null,
"invoice_id": null
} }/v1/imports/:id/emit202 AcceptedIssues every valid row, in chunks of 50, with one idempotency key per line: calling it twice never issues twice. If the plan limit or the credits run out, the remaining rows are left as awaiting_credits (not errors). After topping up, **call it again**: only those rows are sent.
curl -X POST "$BASE_URL/imports/576905ae-1707-4848-9ed6-5a6e51800b74/emit" \
-H "X-API-Key: $CORVO_API_KEY"Response — 202 Accepted: the import, now emitting
{
"data": {
"id": "576905ae-1707-4848-9ed6-5a6e51800b74",
"status": "emitting",
"file_name": "notas-novembro.csv",
"company_id": null,
"error_code": null,
"rows_expected": 300,
"unknown_columns": [],
"emit_started_at": null,
"emit_finished_at": null,
"created_at": "2026-11-07T13:05:21.000Z",
"counters": {
"total": 300,
"valid": 297,
"invalid": 3,
"accepted": 0,
"authorized": 0,
"awaiting_credits": 0,
"valid_amount": 718602.19,
"awaiting_amount": 0,
"failed": 3,
"pending": 297,
"authorized_amount": 0,
"pending_amount": 718602.19,
"failed_amount": 0
}
}
}Other routes
| Method | Route | Description | Status |
|---|---|---|---|
| DELETE | /v1/imports/:id | Discards an import that was not emitted (nothing is issued). | 204 |
| GET | /v1/imports/template | Downloads the spreadsheet template (CSV). | 200 |
| GET | /v1/imports/:id/errors.csv | Downloads only the rows that need attention, streamed. Rows that only wait for credits are left out. | 200 |
When credits run out
Running out of credits is not a failure of the spreadsheet. Issuing stops, the rows that were not sent are kept as awaiting_credits (ERROR_MONTHLY_LIMIT_EXCEEDED, ERROR_INSUFFICIENT_CREDITS and similar codes on the rows) and nothing is lost. Top up your credits and call POST /v1/imports/:id/emit again: only those rows are sent, with the same keys. You can find the import later with GET /v1/imports.
Through MCP
Assistants can run the same flow with the tools import_create, import_list, import_get, import_rows, import_fix_row, import_emit and import_discard (see the MCP page). A spreadsheet sent through MCP is text inside the message: CSV in content (or an .xlsx in content_base64), up to 1 MB; bigger files go through POST /v1/imports.