We use cookies and similar technologies, including Google Analytics and the Meta Pixel, to improve your experience, measure site traffic and understand signup campaigns. By clicking "Accept", you agree to this use.

Learn more about our privacy practices
Corvo
Português

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/imports uploads the file and answers right away with the import id.
  • Poll GET /v1/imports/:id until status is ready. Each row is checked on its own: a bad row never stops the others.
  • Optional review: GET /v1/imports/:id/rows?status=invalid shows the problems, PATCH /v1/imports/:id/rows/:line fixes one, GET /v1/imports/:id/errors.csv downloads only the errors.
  • POST /v1/imports/:id/emit issues the valid rows. Follow the counters until status is done.

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.

ColumnRequiredWhat it is
cnpj_prestadorif no company is chosenCNPJ of the company that issues the row.
tomador_documentonoCustomer CPF or CNPJ, with or without punctuation. Leading zeros lost by Excel are restored.
tomador_nomenoCustomer name.
tomador_emailnoCustomer e-mail, to send the invoice.
descricaoyesService description.
valoryesAmount in BRL, up to 2 decimals.
codigo_serviconoNational service code (6 digits). Empty uses the company default.
data_competencianoCompetence date. Empty uses today (America/Sao_Paulo).
pedidonoPurchase order or service order number printed on the invoice (up to 60 characters).
observacoesnoFree text printed on the invoice as complementary information (up to 2,000 characters).
tomador_nif, tomador_sem_nifnoCustomer 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_complementofor a customer abroadAddress 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

RuleDetail
LimitsUp to 2,000 rows and 10 MB per file; .csv or .xlsx only. More rows fail the import with ERROR_IMPORT_TOO_MANY_ROWS.
CompanyFrom 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).
EnvironmentAn import belongs to the environment of the key that created it: a test key only sees test imports, a live key only live ones.
IdempotencyEach line has its own key (import:<id>:<line>), so repeating emit or retrying a job never issues a line twice.
Not supportedSCHEDULE and RECURRING are not accepted inside a spreadsheet.
PermissionsReading (GET) needs invoices_view; upload, fix, emit and discard need invoices_issue.

Status and counters

statusMeaning
processingThe file is being read and every row checked.
readyRead. You can review, fix and emit.
emittingIssuing. Some invoices may still be waiting for the city.
doneNothing is left in progress. Rows awaiting credits can still be issued again.
failedThe 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).

POST/v1/imports202 Accepted
full_accessinvoices_issueinvoices_read_write

Uploads 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

FieldTypeRequiredDescription
filefile✓The .csv or .xlsx spreadsheet.
companyIdstring (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 } }
GET/v1/imports200 OK
full_accessinvoices_viewinvoices_read_write

Lists 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

FieldTypeRequiredDescription
limitinteger1 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 */ ] }
GET/v1/imports/:id200 OK
full_accessinvoices_viewinvoices_read_write

Status 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
    }
  }
}
GET/v1/imports/:id/rows200 OK
full_accessinvoices_viewinvoices_read_write

The 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

FieldTypeRequiredDescription
statusstringvalid, invalid, queued, accepted or rejected.
limitinteger1 to 200, default 50; more answers 400.
cursorstringnextCursor 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 } }
PATCH/v1/imports/:id/rows/:line200 OK
full_accessinvoices_issueinvoices_read_write

Corrects 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.

FieldTypeRequiredDescription
itemobject✓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
} }
POST/v1/imports/:id/emit202 Accepted
full_accessinvoices_issueinvoices_read_write

Issues 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

MethodRouteDescriptionStatus
DELETE/v1/imports/:idDiscards an import that was not emitted (nothing is issued).204
GET/v1/imports/templateDownloads the spreadsheet template (CSV).200
GET/v1/imports/:id/errors.csvDownloads 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.