The 4invoices public API pushes invoicing data from your system into 4invoices and, for Romania, takes the invoice all the way through ANAF's SPV. One production host, one client_credentials flow, idempotent fiscal writes and one error envelope on every route.
https://api.4invoices.net/v1 · OpenAPI spec (no key needed)
What the 4invoices API is
- The 4invoices public API is REST over HTTPS, JSON in and JSON out.
- One production host: https://api.4invoices.net/v1. There is no separate sandbox host.
- The OpenAPI 3.1 spec is public and keyless — import it straight into Postman or a client generator.
- Every response carries an X-Request-Id header; quote it when you ask for support.
- Every fiscal write is idempotent: a retry after a timeout never issues a second document.
- For Romania, the full ANAF e-Factura round-trip over the API: preflight, submit, tracking, signed response and the inbound inbox. No Romanian competitor exposes this.
- SmartBill has no endpoint for national e-invoicing submission (ANAF, in Romania) — SPV sending is a UI-only account setting — and no invoice-listing endpoint at all.
- Oblio can only create clients and products as a side effect of issuing a document, not as first-class resources.
- UBL / CIUS-RO XML generation (Romania) without submitting: you get the legal document as a file without touching the SPV.
- Idempotency on fiscal writes: a retry after a timeout does not produce a second invoice or burn a second number.
- A programmatically readable inbox of supplier invoices — the basis for automated reconciliation.
Your first invoice in 6 steps
- Mint a credential in the panel under Invoicing → Settings → API access and copy the client_secret — it is shown once.
- Complete the company profile and activate at least one series of kind fiscale, otherwise issuance returns issuer_profile_incomplete or no_series_configured.
- Mint a credential in the panel under Invoicing → Settings → API access and copy the client_secret — it is shown once.
- Complete the company profile and activate at least one series of kind fiscale, otherwise issuance returns issuer_profile_incomplete or no_series_configured.
- Exchange the credential for a token: POST /v1/auth/token with grant_type client_credentials. The token lasts one hour — cache it, do not mint one per request.
- Issue the invoice: POST /v1/invoices with an Idempotency-Key header and vat_rate as a percent (21, not 0.21).
- For the Romanian market, run the CIUS-RO preflight with POST /v1/invoices/{id}/efactura/validate, then send it to ANAF with POST /v1/invoices/{id}/efactura/submit.
- Track the state with GET /v1/efactura/submissions/{id} until it reaches accepted or rejected.
bash
# 1 — token (1 hour, cache it)
ACCESS_TOKEN=$(curl -sS -X POST https://api.4invoices.net/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{"grant_type":"client_credentials","client_id":"cli_XXX","client_secret":"sec_XXX"}' \
| python3 -c 'import sys,json; print(json.load(sys.stdin)["access_token"])')
# 2 — issue the invoice (Idempotency-Key is REQUIRED)
curl -sS -X POST https://api.4invoices.net/v1/invoices \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-10457-invoice' \
-d '{"buyer":{"name":"Exemplu Comert SRL","tax_id":"RO12345678","county":"Cluj","city":"Cluj-Napoca","country":"RO"},
"lines":[{"description":"Abonament SaaS","quantity":1,"unit_price":400,"vat_rate":21}]}'
# 3 — preflight CIUS-RO, then send to ANAF
curl -sS -X POST https://api.4invoices.net/v1/invoices/inv_01J7ABC/efactura/validate \
-H "Authorization: Bearer $ACCESS_TOKEN"
curl -sS -X POST https://api.4invoices.net/v1/invoices/inv_01J7ABC/efactura/submit \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Idempotency-Key: efactura-inv_01J7ABC-1'javascript
const BASE = "https://api.4invoices.net/v1";
const token = await fetch(`${BASE}/auth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.FOURINVOICES_CLIENT_ID,
client_secret: process.env.FOURINVOICES_CLIENT_SECRET,
}),
}).then((r) => r.json()).then((d) => d.access_token);
const invoice = await fetch(`${BASE}/invoices`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${orderId}-invoice`,
},
body: JSON.stringify({
buyer: { name: "Exemplu Comert SRL", tax_id: "RO12345678", county: "Cluj", city: "Cluj-Napoca", country: "RO" },
lines: [{ description: "Abonament SaaS", quantity: 1, unit_price: 400, vat_rate: 21 }],
currency: "RON",
metadata: { order_id: String(orderId) },
}),
}).then((r) => r.json());
console.log(invoice.number, invoice.totals); // "FCT-000123" { net, vat, gross }python
import os, uuid, requests
BASE = "https://api.4invoices.net/v1"
token = requests.post(f"{BASE}/auth/token", json={
"grant_type": "client_credentials",
"client_id": os.environ["FOURINVOICES_CLIENT_ID"],
"client_secret": os.environ["FOURINVOICES_CLIENT_SECRET"],
}, timeout=30).json()["access_token"]
headers = {
"Authorization": f"Bearer {token}",
# One key per LOGICAL operation — reuse it on retry, never regenerate.
"Idempotency-Key": f"order-{order_id}-invoice",
}
invoice = requests.post(f"{BASE}/invoices", headers=headers, json={
"buyer": {"name": "Exemplu Comert SRL", "tax_id": "RO12345678",
"county": "Cluj", "city": "Cluj-Napoca", "country": "RO"},
"lines": [{"description": "Abonament SaaS", "quantity": 1,
"unit_price": 400, "vat_rate": 21}], # percent, not 0.21
"currency": "RON",
}, timeout=60).json()
print(invoice["number"], invoice["totals"])Authentication
- The flow is client_credentials: you exchange client_id + client_secret for a Bearer access_token.
- The token expires in 3600 seconds. Refresh it before expiry, not on every call — POST /auth/token has its own per-IP rate limit.
- The client_secret is stored only as a hash and cannot be recovered. Lose it and you mint a new credential and revoke the old one.
- Server-to-server only. No browser origin is allowed through CORS: from page JavaScript the secret would leak to the visitor.
- Scopes are granted per credential. Issue a separate credential per system and give each one the minimum scopes it needs.
Invoicing → Settings → API access
bash
curl -sS -X POST https://api.4invoices.net/v1/auth/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "cli_XXXXXXXXXXXX",
"client_secret": "sec_XXXXXXXXXXXXXXXXXXXX"
}'
# → {"access_token":"eyJ...","token_type":"Bearer","expires_in":3600,
# "scope":"invoices:read invoices:write efactura:submit"}javascript
// Cache the token; do not mint one per request.
let cached = { token: "", expiresAt: 0 };
async function getToken() {
if (cached.token && Date.now() < cached.expiresAt - 60_000) return cached.token;
const res = await fetch("https://api.4invoices.net/v1/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.FOURINVOICES_CLIENT_ID,
client_secret: process.env.FOURINVOICES_CLIENT_SECRET,
}),
});
if (!res.ok) throw new Error(`token ${res.status}`);
const data = await res.json();
cached = { token: data.access_token, expiresAt: Date.now() + data.expires_in * 1000 };
return cached.token;
}VAT rates are percents
vat_rate: 21 ✓ · vat_rate: 0.19 ✗ · vat_rate: 1 ✗- vat_rate is a PERCENT: 21 means 21%.
- Values in (0, 1] are rejected — 0.19 is a fraction, and 1 is ambiguous between 1% and 100%.
- 0 is valid and means exempt or reverse charge; put the legal mention in notes.
- unit_price is the NET unit price when the issuer is a VAT payer.
- Need the totals before you commit? POST /v1/invoices/preview-totals runs the same arithmetic without consuming a fiscal number.
Idempotency on fiscal writes
- The Idempotency-Key header is REQUIRED on POST /invoices, POST /invoices/{id}/storno and POST /invoices/{id}/efactura/submit.
- Keys are remembered for 24 hours. Replaying the same key with the same body returns the original result and sets Idempotency-Replayed: true.
- The same key with a different body returns 409 idempotency_key_reuse — an already-issued fiscal document is never overwritten.
- One key per LOGICAL operation, not per HTTP attempt: order-10457-invoice, not a fresh UUID on every retry.
- The reason is legal, not technical: a consumed fiscal number and an invoice filed in the national e-invoicing system cannot be withdrawn.
bash
curl -sS -X POST https://api.4invoices.net/v1/invoices \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-10457-invoice' \
-d '{
"buyer": {
"name": "Exemplu Comert SRL",
"tax_id": "RO12345678",
"address": "Str. Fabricii 12",
"city": "Cluj-Napoca",
"county": "Cluj",
"country": "RO",
"email": "[email protected]"
},
"lines": [
{ "description": "Abonament SaaS, iulie", "quantity": 1, "unit_price": 400, "vat_rate": 21, "unit": "buc" },
{ "description": "Implementare", "quantity": 6, "unit_price": 250, "vat_rate": 21, "unit": "ora" }
],
"currency": "RON",
"document_kind": "factura",
"due_date": "2026-08-15",
"metadata": { "order_id": "10457" }
}'Every endpoint
| Method | Endpoint | Scope | Idempotency-Key | Requests / minute |
|---|---|---|---|---|
| POST | /auth/token Exchange credentials for an access token | — | — | 120 |
| GET | /me Tenant, scopes and current limits | invoices:read | — | 120 |
| POST | /invoices Issue an invoice or proforma | invoices:write | required | 30 |
| GET | /invoices List invoices (cursor-paginated) | invoices:read | — | 120 |
| POST | /invoices/preview-totals Compute totals without creating anything | invoices:read | — | 120 |
| GET | /invoices/{invoice_id} Retrieve one invoice | invoices:read | — | 120 |
| GET | /invoices/{invoice_id}/pdf Download the invoice PDF | invoices:read | — | 120 |
| GET | /invoices/{invoice_id}/xml Download the UBL / CIUS-RO XML | invoices:read | — | 120 |
| POST | /invoices/{invoice_id}/storno Issue a credit note reversing this invoice | invoices:write | required | 30 |
| POST | /invoices/{invoice_id}/payments Mark the invoice as paid | invoices:write | — | 30 |
| DELETE | /invoices/{invoice_id}/payments Mark the invoice as unpaid | invoices:write | — | 30 |
| GET | /clients List clients | clients:read | — | 120 |
| POST | /clients Create or update a client | clients:write | — | 30 |
| GET | /lookup/company/{tax_id} Validate a CUI against the ANAF registry (Romania) | clients:read | — | 120 |
| GET | /series List numbering series | invoices:read | — | 120 |
| POST | /invoices/{invoice_id}/efactura/validate CIUS-RO preflight — validate without sending | efactura:read | — | 120 |
| POST | /invoices/{invoice_id}/efactura/submit Submit the invoice to ANAF (SPV, Romania) | efactura:submit | required | 10 |
| GET | /efactura/submissions List ANAF submissions (Romania) | efactura:read | — | 120 |
| GET | /efactura/submissions/{submission_id} Retrieve one submission | efactura:read | — | 120 |
| POST | /efactura/submissions/{submission_id}/refresh Ask ANAF for the current state (Romania) | efactura:read | — | 10 |
| GET | /efactura/submissions/{submission_id}/download Download the signed response archive | efactura:read | — | 120 |
| GET | /efactura/inbox Supplier invoices received via the SPV (Romania) | efactura:read | — | 120 |
| GET | /efactura/inbox/{inbound_id} Retrieve one inbound invoice | efactura:read | — | 120 |
| GET | /efactura/inbox/{inbound_id}/download Download the inbound invoice archive | efactura:read | — | 120 |
| POST | /efactura/inbox/sync Pull new SPV messages into the inbox (Romania) | efactura:read | — | 10 |
25 endpoints · https://api.4invoices.net/v1/openapi.json
Scopes
- invoices:read
- invoices:write
- clients:read
- clients:write
- efactura:read
- efactura:submit
The ANAF e-Factura round-trip (Romania)
- The real differentiator: for Romania, the whole ANAF e-Factura round-trip runs over the API, not just document generation.
- POST /invoices/{id}/efactura/validate runs the CIUS-RO preflight (Romania) and returns the exact fields ANAF would reject, without sending anything.
- GET /invoices/{id}/xml generates the UBL / CIUS-RO XML (the Romanian profile) with no submission — useful for your own archive or an internal audit.
- POST /invoices/{id}/efactura/submit files it in the SPV (ANAF, Romania). This is irreversible: a second copy of the same invoice cannot be withdrawn from the Romanian SPV.
- The lifecycle is normalized: not_sent → validating → queued → sent → processing → accepted | rejected | error. accepted means legally transmitted; error is a transport failure on our side and is safe to retry.
- GET /efactura/submissions/{id}/download fetches the archive with the signed response from the Romanian ANAF.
- The inbound side exists too: POST /efactura/inbox/sync pulls new SPV messages (ANAF, Romania) and GET /efactura/inbox lists the invoices your suppliers sent you.
bash
# CIUS-RO preflight. Sends NOTHING to ANAF.
curl -sS -X POST "https://api.4invoices.net/v1/invoices/inv_01J7ABC/efactura/validate?include_xml=true" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# → {"invoice_id":"inv_01J7ABC","valid":false,
# "errors":["buyer.county is required for RO buyers"],
# "xml_base64":"PD94bWwg..."}bash
curl -sS -X POST https://api.4invoices.net/v1/invoices/inv_01J7ABC/efactura/submit \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H 'Idempotency-Key: efactura-inv_01J7ABC-1'
# → {"submission_id":"sub_01J7XYZ","status":"queued"}python
import time, requests
BASE = "https://api.4invoices.net/v1"
TERMINAL = {"accepted", "rejected"}
H = {"Authorization": f"Bearer {token}"}
def wait_for_anaf(submission_id, timeout=900):
deadline = time.time() + timeout
delay = 15
while time.time() < deadline:
r = requests.get(f"{BASE}/efactura/submissions/{submission_id}", headers=H, timeout=30)
r.raise_for_status()
status = r.json()["status"]
if status in TERMINAL:
return r.json()
# /refresh asks ANAF directly and shares the 10/min submit bucket.
requests.post(f"{BASE}/efactura/submissions/{submission_id}/refresh", headers=H, timeout=30)
time.sleep(delay)
delay = min(delay * 2, 120)
raise TimeoutError(submission_id)Errors
- Every 4xx and 5xx uses the same envelope: { "error": { type, code, message, param?, doc_url, request_id, errors? } }.
- type is the coarse class you branch on ("can I retry?"), and code is the stable reason you map into your own system.
- param is the dotted path of the offending field, for example lines.0.vat_rate.
- errors[] appears when a single request has problems on more than one field.
- request_id is logged on our side. Quote it in any support ticket — it pins the exact request.
- A 404 is also returned for an id that belongs to another tenant: we never confirm that someone else's data exists.
json
{
"error": {
"type": "invalid_request_error",
"code": "validation_failed",
"message": "Datele trimise nu sunt valide.",
"param": "lines.0.vat_rate",
"doc_url": "https://4invoices.net/ro/api-dezvoltatori#errors",
"request_id": "req_8a893f2f33d4472da8d6e144",
"errors": [
{ "param": "lines.0.vat_rate", "message": "VAT rate must be a percent (21), not a fraction", "type": "value_error" }
]
}
}| Status | Type | Code |
|---|---|---|
| 401 | authentication_error | missing_bearer_token |
| 401 | authentication_error | invalid_client |
| 401 | authentication_error | token_expired |
| 401 | authentication_error | invalid_token |
| 401 | authentication_error | unauthorized |
| 403 | permission_error | insufficient_scope |
| 403 | permission_error | forbidden |
| 404 | not_found_error | not_found |
| 409 | conflict_error | idempotency_key_reuse |
| 409 | conflict_error | idempotency_in_progress |
| 409 | conflict_error | no_series_configured |
| 422 | invalid_request_error | validation_failed |
| 422 | invalid_request_error | issuer_profile_incomplete |
| 422 | invalid_request_error | cius_ro_incomplete |
| 422 | invalid_request_error | cannot_storno_draft |
| 422 | invalid_request_error | nothing_to_storno |
| 422 | invalid_request_error | invalid_json_number |
| 429 | rate_limit_error | rate_limit_exceeded |
| 502 | upstream_error | upstream_error |
| 503 | service_unavailable_error | service_unavailable |
Rate limits
| Bucket | Requests / minute | What it means |
|---|---|---|
| read | 120 | GET *, POST /invoices/preview-totals, POST /invoices/{id}/efactura/validate |
| write | 30 | POST /invoices, POST /invoices/{id}/storno, POST|DELETE /invoices/{id}/payments, POST /clients |
| submit | 10 | POST /invoices/{id}/efactura/submit, POST /efactura/submissions/{id}/refresh, POST /efactura/inbox/sync |
- Limits are per credential, per minute: 120 for reads, 30 for writes, 10 for the e-invoicing operations (submit, refresh, inbox/sync).
- X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset appear on EVERY response, including the 429.
- A 429 also carries Retry-After in seconds. Honour it; do not retry in a tight loop.
- The 10/min bucket exists because in Romania ANAF itself allows roughly one message-list call per minute per CIF.
- Put /efactura/inbox/sync on a cron, not on an aggressive poll.
- Need higher ceilings? Ask via Contact with your expected daily volume.
X-RateLimit-Limit · X-RateLimit-Remaining · X-RateLimit-Reset · Retry-After
http
HTTP/2 429
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785312000
Retry-After: 17
Content-Type: application/json
{"error":{"type":"rate_limit_error","code":"rate_limit_exceeded",
"message":"...","doc_url":"https://4invoices.net/ro/api-dezvoltatori#errors",
"request_id":"req_..."}}javascript
// Honour Retry-After; never retry a 4xx other than 429.
async function call(url, init, attempt = 0) {
const res = await fetch(url, init);
if (res.status === 429 || res.status >= 500) {
if (attempt >= 5) return res;
const retryAfter = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, retryAfter * 1000));
return call(url, init, attempt + 1);
}
return res;
}
// A write retry MUST reuse the SAME Idempotency-Key, otherwise you issue
// a second fiscal document.Guides by topic
Each topic has its own page with the relevant endpoints, samples and errors.
- Authenticating with the 4invoices APIExchange client_id and client_secret for a one-hour Bearer token, server-to-server only.
- Issuing invoices over the APIPOST /invoices with an Idempotency-Key, VAT as percents, plus listing, storno and marking paid.
- Clients and ANAF registry lookup (Romania)A tax_id-deduplicated upsert plus an ANAF registry lookup for Romanian companies, so the e-Factura is not rejected.
- ANAF e-Factura (Romania) over the APIFor Romania: CIUS-RO preflight, SPV submission, normalized status, signed response and the inbound supplier inbox.
- Numbering series in the APIWithout an active series of kind fiscale, issuance returns 409 no_series_configured.
- Error codes and the error envelopeOne shape on every route: type, code, message, param, doc_url, request_id and errors.
- Rate limits and retries120 reads, 30 writes and 10 e-invoicing operations per minute per credential, with X-RateLimit-* headers.
Common mistakes
- Sending VAT as a fraction (0.19) or as 1. Both are rejected; send 19 or 21.
- Minting a token per request. The token lasts an hour; cache it or you will hit the token endpoint's per-IP limit.
- Generating a fresh idempotency key on each retry — precisely the scenario that produces duplicate invoices.
- Calling from browser JavaScript. CORS blocks every origin, and the secret would be exposed anyway.
- Filing in the national e-invoicing system without a preflight (CIUS-RO in Romania). A wrong submit cannot be withdrawn.
- Polling /efactura/submissions/{id}/refresh more than 10 times a minute — that bucket is shared with submit.
- Issuing before the company profile is complete and an active fiscal series exists.
Developer questions and answers
How do I get 4invoices API credentials?
In the panel, under Invoicing → Settings → API access. You get a client_id and a client_secret; the secret is shown once and stored only as a hash.
How long is the access_token valid?
3600 seconds, i.e. one hour. Cache it and refresh before expiry — the token endpoint has its own per-IP rate limit.
How do I send the VAT rate?
As a percent: 21 for 21%. Values in (0, 1] are rejected, so 0.19 and 1 both fail validation.
Which calls require an Idempotency-Key?
POST /invoices, POST /invoices/{id}/storno and POST /invoices/{id}/efactura/submit. Keys are remembered for 24 hours, and a replay returns the original document with Idempotency-Replayed: true.
Can I submit an invoice to ANAF e-Factura (Romania) straight from the API?
Yes, for the Romanian market: run the CIUS-RO preflight with /efactura/validate, file it with /efactura/submit, poll the normalized status on /efactura/submissions/{id} and download ANAF's signed response.
What are the rate limits?
Per credential, per minute: 120 reads, 30 writes and 10 e-invoicing operations. Every response carries X-RateLimit-* headers, and a 429 adds Retry-After.
Can I call the API from my frontend?
No. It is server-to-server only: no browser origin passes CORS, and a client_secret has no business in code that reaches the visitor.
Why do I get issuer_profile_incomplete on my first invoice?
The company profile is incomplete or there is no active series of kind fiscale. Fill both in the panel; the API refuses to produce an invalid fiscal document.