API reference
Authentication, Idempotency-Key, the invoice endpoints and the shape of errors. Everything the panel does goes through this API.
The API lives under /api/v1 and speaks JSON. The panel uses it for every action, and the OpenAPI document generated from the running application is versioned in the repository as docs/api/openapi.json.
Authentication
- A session starts at sign-in (through the Quathos Account when it is enabled for the organisation) and lives in the HttpOnly cookie qinv_session for 12 hours. The server stores only its hash.
- Every POST, PUT, PATCH and DELETE must echo the qinv_csrf cookie in the x-csrf-token header. Missing or different: 403 csrf.
- The organisation comes from the session, never from the request. The role decides what you can call: accountant reads, member issues, admin cancels and manages.
- Failures: 401 unauthenticated or session_expired; 403 forbidden when the role is too low; 429 too_many_attempts after repeated failed sign-ins.
Idempotency-Key
POST /api/v1/invoices accepts an Idempotency-Key header of up to 120 characters. The first request with a key creates the invoice; any later request with the same key, in the same organisation, returns that same invoice instead of creating another. The key is kept with the invoice and does not expire. The body of the repeated request is not compared, so use one key per invoice you mean to create, such as your own order number.
POST /api/v1/invoices
Idempotency-Key: pedido-2026-10-0042
x-csrf-token: <qinv_csrf>
content-type: application/json
{
"customer_id": "6f1c…",
"service_id": "a03e…",
"amount_services": "1500.00",
"competence_date": "2026-10-01",
"description": "Consultoria — outubro/2026",
"external_ref": "pedido-2026-10-0042",
"emit": true
}Invoice endpoints
| Call | Role | What it does |
|---|---|---|
| POST /invoices/preview | any | Computes ISS base, ISS, withholdings and net amount, and lists problems. Sends nothing. |
| GET /invoices | any | Lists invoices, newest first. Filters: status (comma-separated), q, period (7d, 30d, month, year, all), limit (up to 200), offset. |
| POST /invoices | member | Creates an invoice (201). With emit true (the default) it is queued for transmission; with false it stays a draft. |
| GET /invoices/{id} | any | The invoice with its events and the verification of the hash chain. |
| POST /invoices/{id}/emit | member | Transmits a draft, or a rejected or failed invoice again. Any other status: 409 invalid_status. |
| POST /invoices/{id}/cancel | admin | Body: reason_code ("1", "2" or "9") and reason (15–255 characters). Only an authorized invoice. Asynchronous: the status becomes canceling. |
| DELETE /invoices/{id} | member | Deletes a draft that never took a number (204). Otherwise 409 not_draft. |
| GET /invoices/{id}/files/{kind} | any | A signed link, valid for 5 minutes, to pdf (DANFSe), xml (NFS-e), dps (signed DPS) or response (government answer). Add /raw to receive the bytes of pdf, xml or dps directly. |
Invoice body fields: customer_id and service_id (required), amount_services (above zero), and optionally iss_rate, iss_withheld, deductions, discount_unconditional, discount_conditional, pis_amount, cofins_amount, inss_amount, ir_amount, csll_amount, other_withholdings, description (up to 2,000), competence_date (not in the future) and external_ref (up to 120). Money travels as decimal strings, never as floating point.
Clients (/customers), services (/services), issuers and certificates (/issuers), recurrences (/recurrences) and batches (/imports) have their own endpoints, listed in the OpenAPI document.
Errors
{ "detail": { "code": "invalid_status", "message": "só nota autorizada pode ser cancelada" } }Compare the code, never the message: the message is written for people and may change; the code is the contract. A 422 from validation also carries fields, a list of { field, message }.
| Code | Status | Meaning |
|---|---|---|
| invalid_request | 422 | The body failed validation; see fields |
| invalid_reference | 422 | Unknown client or service |
| competence_in_future | 422 | Competence after today |
| municipal_code_required | 422 | The issuer’s city requires a municipal tax code on the service |
| provider_not_ready | 422 | The issuer’s route is not open for issuing yet |
| reason_too_short | 422 | Cancellation justification under 15 characters |
| issuer_missing | 404 | No issuer registered for the organisation |
| file_missing | 404 | The file was not generated yet |
| invalid_status | 409 | The invoice is not in a status that allows the call |
| not_draft | 409 | Only a draft without a number can be deleted |
| too_large | 413 | Body above the limit |
Government rejections
A rejection is not an HTTP error: the call that queued the invoice already succeeded. The invoice ends rejected, with last_error_code (the first code the government returned, such as E0312) and last_error_message (every code with its message). The full list, with the correction the government suggests, is in the rejection event. Fix the data and call /emit again. An invoice in failed did not get an answer; it is retried on its own and its last_error_code is transport or http_<status>.