4invoices developer API

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.
Server-to-server only. Never call the API from browser JavaScript: the client_secret would reach the visitor, and CORS blocks every origin anyway.
  • 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

Before your first call
  1. Mint a credential in the panel under Invoicing → Settings → API access and copy the client_secret — it is shown once.
  2. Complete the company profile and activate at least one series of kind fiscale, otherwise issuance returns issuer_profile_incomplete or no_series_configured.
  1. Mint a credential in the panel under Invoicing → Settings → API access and copy the client_secret — it is shown once.
  2. Complete the company profile and activate at least one series of kind fiscale, otherwise issuance returns issuer_profile_incomplete or no_series_configured.
  3. 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.
  4. Issue the invoice: POST /v1/invoices with an Idempotency-Key header and vat_rate as a percent (21, not 0.21).
  5. 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.
  6. 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

MethodEndpointScopeIdempotency-KeyRequests / 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:writerequired30
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:writerequired30
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:submitrequired10
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)

not_sentvalidatingqueuedsentprocessingacceptedrejectederror
  • 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" }
    ]
  }
}
StatusTypeCode
401authentication_errormissing_bearer_token
401authentication_errorinvalid_client
401authentication_errortoken_expired
401authentication_errorinvalid_token
401authentication_errorunauthorized
403permission_errorinsufficient_scope
403permission_errorforbidden
404not_found_errornot_found
409conflict_erroridempotency_key_reuse
409conflict_erroridempotency_in_progress
409conflict_errorno_series_configured
422invalid_request_errorvalidation_failed
422invalid_request_errorissuer_profile_incomplete
422invalid_request_errorcius_ro_incomplete
422invalid_request_errorcannot_storno_draft
422invalid_request_errornothing_to_storno
422invalid_request_errorinvalid_json_number
429rate_limit_errorrate_limit_exceeded
502upstream_errorupstream_error
503service_unavailable_errorservice_unavailable

Rate limits

BucketRequests / minuteWhat it means
read120GET *, POST /invoices/preview-totals, POST /invoices/{id}/efactura/validate
write30POST /invoices, POST /invoices/{id}/storno, POST|DELETE /invoices/{id}/payments, POST /clients
submit10POST /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.

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.