API reference

One REST API over the same endpoints the panel uses — so it cannot fall behind the product. JSON in, JSON out, keys in the Authorization header.

Idempotent writesA retried POST returns the first response instead of issuing a second invoice. The key is reserved before the operation runs, so two simultaneous requests cannot both go through.
Cursor paginationStable while rows are being inserted. offset still works for code that already uses it.
Signed webhooksHMAC over timestamp.body, event ids stable across retries, backoff, manual redelivery, a visible log, and automatic pausing of dead endpoints.
Dry-run totalsPOST /documents/calculate returns the exact VAT and totals a document would carry, and creates nothing.
A real sandboxA separate organization with its own numbering and keys — not a flag on live data that leaks test invoices into your VAT return.
Errors you can branch onA stable code, the offending field, and an X-Request-Id to quote at support.

Base URL: https://oltrano.com/api/v1 · Current version: 2026-09-01

Authentication

Create a key in Team → API keys. It is shown once; we store only a hash. Keys are scoped to one organization and carry either read or write access.

curl https://oltrano.com/api/v1/documents?kind=invoice \
  -H "Authorization: Bearer olt_live_xxxxxxxxxxxxxxxxxxxx"
A read key cannot write. Give your reporting job a read key and your automation a write key, so a mistake in a report cannot issue a document.

Sandbox

A sandbox is a separate organization with its own data, numbering and API keys. It is never billed and can be emptied in one call. We deliberately did not build a “test mode” flag on live data: that is how test invoices end up in a real VAT return.

# Create it once (owner only, session or key)
curl -X POST https://oltrano.com/api/v1/sandbox -H "Authorization: Bearer olt_..."

# Then make a key inside the sandbox org and point your staging environment at it.
# When you want a clean slate:
curl -X POST https://oltrano.com/api/v1/sandbox/reset -H "Authorization: Bearer olt_sandbox_key..."

Reset refuses to run on a non-sandbox organization. Real data is never wiped by an API call.

Versioning

Every response carries Oltrano-Version: 2026-09-01. Additive changes — a new field, a new endpoint, a new event — ship without a version bump, so ignore unknown fields. Anything that could break a working integration gets a new dated version, and the old one keeps answering.

Idempotency

Send Idempotency-Key on every POST. If the same key arrives again, you get the first response back, with Idempotent-Replay: true — no second document. Keys live 24 hours.

curl -X POST https://oltrano.com/api/v1/documents \
  -H "Authorization: Bearer olt_..." \
  -H "Idempotency-Key: order-8841-invoice" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "invoice",
    "customer_id": "cus_4264ad7abcd788f08117d99b",
    "issue": true,
    "lines": [
      { "name": "Consulting", "qty": 10, "unit": "h", "unit_price_cents": 15000, "tax_rate": 21 }
    ]
  }'
Reusing a key with a different body is an error, not a replay: you get 409 idempotency_conflict. That is deliberate — silently returning some other operation's result is worse than failing. Use a key derived from your object, like order-8841-invoice.

Only successful responses are remembered. A 500 can be retried with the same key and can then succeed.

Pagination

Lists return has_more and next_cursor. Pass the cursor back to continue. The cursor encodes the last row seen, so inserts happening while you page do not make rows appear twice or vanish — which is what offset does.

GET https://oltrano.com/api/v1/documents?kind=invoice&limit=100
{
  "docs": [ ... ],
  "total": 4812,
  "outstanding_cents": 1284900,
  "has_more": true,
  "next_cursor": "WyIyMDI2LTA5LTE0IiwiZG9jXzhlMiJd"
}

GET https://oltrano.com/api/v1/documents?kind=invoice&limit=100&cursor=WyIyMDI2LTA5LTE0IiwiZG9jXzhlMiJd

offset is still accepted so existing integrations keep working. For anything over a few hundred rows, use the cursor.

Errors

Errors are JSON with a stable code. Branch on code, never on message — messages are written for humans and get reworded.

{
  "error": "Add at least one line before issuing",
  "code": "invalid_field",
  "field": "lines",
  "docs": "/api-docs#errors",
  "request_id": "req_9f2c41ab77e0"
}
CodeHTTPMeans
invalid_request400The request body could not be parsed, or a field has the wrong type.
missing_field400A required field is absent. `field` says which.
invalid_field400A field is present but not acceptable. `field` says which.
unauthorized401No API key, or the key is unknown or revoked.
forbidden403The key is valid but its scope or the role does not allow this.
not_found404No object with that id in this organization.
conflict409The object exists, or the operation conflicts with its current state.
immutable409The document is issued. Issue a credit note instead of editing it.
idempotency_conflict409The same Idempotency-Key was used with a different body.
rate_limited429Too many requests. `Retry-After` says how long to wait.
subscription_inactive403The subscription does not allow issuing new documents.
internal500Our fault. The response carries a request id — send it to support.

Every response carries X-Request-Id. Send it with a support question and we can find the exact call.

Rate limits

Per key, per minute: 600 reads and 120 writes. The counters are on every response, not only on 429, so you can slow down before you are stopped.

X-RateLimit-Limit: 120
X-RateLimit-Remaining: 117
X-RateLimit-Reset: 1790733600

On 429 you also get Retry-After in seconds. Need more for a migration? Ask — we would rather raise it than watch you retry in a loop.

Money, dates and language

Customers

GET/v1/customers

List and search. ?q= matches name, email, VAT ID and city.

POST/v1/customers

Create. A duplicate tax ID returns 409 conflict unless you pass confirm_duplicate: true.

GET/v1/customers/:id

One customer, with the open balance.

PUT/v1/customers/:id

Update. Changing the tax ID clears the VIES verification — a stale “valid” would be a lie on the new number.

POST/v1/customers/:id/check-vat

Verify the VAT number in VIES and record the date. Reverse charge needs this.

DELETE/v1/customers/:id

Archive if the customer has documents, delete if not.

curl -X POST https://oltrano.com/api/v1/customers \
  -H "Authorization: Bearer olt_..." -H "Content-Type: application/json" \
  -d '{
    "name": "Kunde GmbH",
    "kind": "company",
    "email": "rechnung@kunde.de",
    "tax_id": "DE123456789",
    "address": "Hauptstr. 5", "city": "Berlin", "zip": "10115", "country": "DE",
    "payment_terms": 30
  }'
Reverse charge is not automatic on “looks foreign”. It applies only when the customer is a company, in another EU member state, with a VAT number we verified in VIES. Call check-vat after creating an EU business, or the invoice will correctly carry VAT.

Items and tax rates

GET/v1/items

The catalogue. Optional — invoice lines can be written freely.

POST/v1/items

Create. tax_rate: null means “use the organization default”, which is not the same as 0 (exempt).

GET/v1/tax-rates

Your rates. If you defined none, the usual rates for your country are suggested.

Documents

One resource for invoices, quotes, credit notes, proformas and recurring templates: same lines, same totals, same lifecycle. kind says which.

GET/v1/documents

List. Filters: kind, status, customer_id, from, to, q.

POST/v1/documents

Create. issue: true also issues it in the same call.

GET/v1/documents/:id

One document with lines, payments, history and related documents.

PUT/v1/documents/:id

Update. A draft can change entirely; an issued document accepts only fields that are not figures.

POST/v1/documents/:id/issue

Draft → issued. Takes the next number. Safe to call twice: it will not consume a second number.

POST/v1/documents/:id/send

Email it to the customer, with the PDF and optionally the EN 16931 XML.

POST/v1/documents/:id/payments

Record a payment.

POST/v1/documents/:id/credit-note

Credit the whole invoice or selected lines. Settles the original automatically.

POST/v1/documents/:id/convert

Quote or proforma → invoice. Refuses to do it twice.

POST/v1/documents/:id/void

Void. The number stays used, as the law requires.

DELETE/v1/documents/:id

Delete a draft. An issued document cannot be deleted — void it.

An issued document is immutable. Editing lines, customer or amounts after issuing returns 409 immutable. That is the rule everywhere invoices are regulated, and working around it is how people end up with two versions of the same number. Corrections are credit notes.

Statuses

StatusMeans
draftNo number yet. Fully editable, deletable.
openIssued, not sent.
sent / viewedEmailed; viewed once the customer opened the link.
partial / paidDerived from recorded payments, not set by hand.
overduePast the due date, unpaid. Set by a job, not only when you look.
voidVoided. Keeps its number.
accepted / declined / expiredQuotes only.

Calculate without creating

The endpoint most billing APIs do not have. It returns the exact figures a document would carry — per line, per rate and in total — and writes nothing. Use it in a cart, a CRM or a quote screen instead of reimplementing VAT.

POST/v1/documents/calculate

Totals and VAT breakdown for a set of lines. Creates nothing.

curl -X POST https://oltrano.com/api/v1/documents/calculate \
  -H "Authorization: Bearer olt_..." -H "Content-Type: application/json" \
  -d '{
    "customer_id": "cus_...",
    "prices_include_tax": true,
    "lines": [ { "name": "Retail item", "qty": 1, "unit_price_cents": 10000, "tax_rate": 21 } ]
  }'

{
  "reverse_charge": false,
  "tax_breakdown": [ { "rate": 21, "net_cents": 8264, "tax_cents": 1735 } ],
  "totals": {
    "net_cents": 8264, "tax_cents": 1735, "gross_cents": 9999,
    "payable_cents": 10000, "rounding_cents": 1
  }
}
Why rounding_cents exists. VAT is computed from the base, as EN 16931 requires — never extracted from the total. At 21% on a tax-inclusive 100.00 no integer base closes exactly, so the leftover cent is declared as payment rounding (BT-114) instead of being hidden inside the VAT figure. Your customer pays 100.00 and the breakdown still adds up.

Payments

Payments belong to a document. Recording one recomputes the status and fires document.paid or document.partially_paid.

curl -X POST https://oltrano.com/api/v1/documents/doc_86.../payments \
  -H "Authorization: Bearer olt_..." -H "Idempotency-Key: bank-tx-99271" \
  -H "Content-Type: application/json" \
  -d '{ "amount_cents": 121000, "method": "bank", "reference": "OP 4411", "received_on": "2026-10-02" }'

Overpayment is accepted on purpose: the money arrived, and a system that refuses reality just gets worked around in a spreadsheet.

PDF, UBL and the customer link

GET/v1/documents/:id/pdf

The PDF, rendered with your template and accent colour.

GET/v1/documents/:id/preview

The same document as HTML. Accepts theme overrides in the query string.

GET/v1/documents/:id/ubl

EN 16931 / Peppol BIS Billing 3.0 XML. The header says whether it validated.

GET/v1/documents/:id/ubl/report

The validation report: rule ids, messages and hints.

Every issued document also has a private customer link at https://oltrano.com/d/<portal_token> — no login, works on a phone, shows when it was opened and can carry a pay button.

Expenses

GET/v1/expenses

List with totals for the period.

POST/v1/expenses

Record one. Give gross, or net, or both — what is missing is derived with the same rule as invoices.

POST/v1/expenses/:id/receipt

Attach a photo or PDF of the receipt. Raw body, up to 10 MB.

Reports

GET/v1/reports/summary

Invoiced, collected, outstanding, overdue, expenses.

GET/v1/reports/aging

Who owes you, bucketed by how late.

GET/v1/reports/vat

VAT collected against VAT deductible, per rate.

GET/v1/reports/profit

Revenue minus expenses, by category.

GET/v1/reports/export

CSV for your accountant. ?what=expenses for the other book.

Reports convert foreign-currency documents at the rate stored on each document at issue time — so last month's total does not move because the euro moved today.

Mail

GET/v1/mail/domains

Domains with their DNS state and the exact records to publish.

POST/v1/mail/domains/:id/verify

Read the zone now. Reports what it found, including the old provider.

GET/v1/mail/mailboxes

Mailboxes, aliases, groups and forwards.

POST/v1/mail/mailboxes

Create one. The password is returned once and never stored.

Aliases, groups and forwards do not count against the plan. Only real mailboxes do.

E-invoicing

GET/v1/fiscal

Providers available for your country, and which one is active.

POST/v1/documents/:id/fiscal

Send the document to the tax authority through the configured provider.

POST/v1/fiscal-docs/:id/sync

Re-ask the authority. In Brazil authorisation is asynchronous, so this matters.

GET/v1/fiscal-calls

The provider call log: what we sent, what came back. Secrets are never written.

Webhooks

Create endpoints in Team → Webhooks or over the API. Each delivery is signed, retried with backoff, logged, and can be redelivered by hand.

POST/v1/webhooks

Create. The secret is returned once. HTTPS only; private and loopback addresses are refused.

POST/v1/webhooks/:id/test

Send a ping event now and return the result, so you can check the endpoint before the first real invoice.

GET/v1/webhook-deliveries

The delivery log with status codes and errors.

POST/v1/webhook-deliveries/:id/redeliver

Send it again, with the same event id so you can deduplicate.

POST /your-endpoint HTTP/1.1
Content-Type: application/json
Oltrano-Event: document.paid
Oltrano-Event-Id: evt_7f31c0a9de24
Oltrano-Delivery-Attempt: 1
Oltrano-Signature: t=1790733612,v1=6f1c...c0

{
  "id": "evt_7f31c0a9de24",
  "type": "document.paid",
  "occurred_at": "2026-09-30T09:20:12.441Z",
  "api_version": "2026-09-01",
  "organization": "org_be36abc9794bf97e37274408",
  "data": { "id": "doc_86...", "number": "INV-2026-0001", "currency": "RON", "payable_cents": 241879 }
}

Retries: after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours. Any 2xx counts as delivered. After repeated failures the endpoint is marked failing and paused, so a dead URL does not queue forever.

Verifying the signature

Compute HMAC-SHA256 over timestamp.rawBody with your secret, on the raw body — parsing and re-serialising changes the bytes and the check will always fail.

import crypto from "node:crypto";

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  # Reject anything older than five minutes: without this, a captured
  # delivery can be replayed at any time with a valid signature.
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret)
    .update(parts.t + "." + rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
We do not guarantee ordering. Two events a moment apart can arrive out of order. Treat the webhook as a nudge: read occurred_at, and when it matters, fetch the object from the API before acting. Any provider promising strict ordering over HTTP is promising something it cannot keep.

Events

EventFires when
document.createdA draft or issued document was created.
document.issuedA document received its number and became final.
document.sentA document was emailed to the customer.
document.viewedThe customer opened the document link for the first time.
document.paidA document became fully paid.
document.partially_paidA payment was recorded but a balance remains.
document.overdueA document passed its due date unpaid.
document.voidedA document was voided.
payment.recordedA payment was recorded, by hand or from a card.
payment.removedA recorded payment was deleted.
estimate.acceptedThe customer accepted a quote.
estimate.declinedThe customer declined a quote.
customer.createdA customer was created.
customer.updatedA customer was changed.
expense.createdAn expense was recorded.
mailbox.provisionedA mailbox became active on the mail server.
domain.verifiedA mail domain passed its DNS checks.
fiscal.acceptedThe tax authority accepted an e-invoice.
fiscal.rejectedThe tax authority rejected an e-invoice.

Subscribe to * to get everything, including events added later.

Batch operations

Create up to 50 objects in one call. You get a result per item, so a partial failure tells you exactly which one and why — instead of “the batch failed”.

POST https://oltrano.com/api/v1/documents/batch
{ "documents": [ { "kind": "invoice", "customer_id": "cus_a", "lines": [...] }, ... ] }

{
  "succeeded": 47,
  "failed": 1,
  "results": [
    { "index": 0, "ok": true, "id": "doc_..." },
    { "index": 12, "ok": false,
      "error": { "code": "not_found", "message": "Pick a customer first", "field": "customer_id" } }
  ]
}

Document design

GET/v1/templates

The template catalogue and your current theme.

GET/v1/templates/sample

A sample document rendered with any theme, via query parameters.

Templates: classic modern minimal bold compact. Set the default in Document design, or override it on a single document with "template": "bold" — allowed even after issuing, because it changes no figures.

Known limits

Said here rather than discovered later:

Something missing, or a limit in your way? Write to us with the X-Request-Id of a call that shows the problem — that is usually enough for us to reproduce it.