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 writes | A 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 pagination | Stable while rows are being inserted. offset still works for code that already uses it. |
| Signed webhooks | HMAC over timestamp.body, event ids stable across retries, backoff, manual redelivery, a visible log, and automatic pausing of dead endpoints. |
| Dry-run totals | POST /documents/calculate returns the exact VAT and totals a document would carry, and creates nothing. |
| A real sandbox | A 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 on | A 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"
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 }
]
}'
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"
}
| Code | HTTP | Means |
|---|---|---|
invalid_request | 400 | The request body could not be parsed, or a field has the wrong type. |
missing_field | 400 | A required field is absent. `field` says which. |
invalid_field | 400 | A field is present but not acceptable. `field` says which. |
unauthorized | 401 | No API key, or the key is unknown or revoked. |
forbidden | 403 | The key is valid but its scope or the role does not allow this. |
not_found | 404 | No object with that id in this organization. |
conflict | 409 | The object exists, or the operation conflicts with its current state. |
immutable | 409 | The document is issued. Issue a credit note instead of editing it. |
idempotency_conflict | 409 | The same Idempotency-Key was used with a different body. |
rate_limited | 429 | Too many requests. `Retry-After` says how long to wait. |
subscription_inactive | 403 | The subscription does not allow issuing new documents. |
internal | 500 | Our 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
- Money is integer minor units:
unit_price_cents: 15000is 150.00. No floats anywhere — a float is how invoices stop adding up. - Currencies without minor units are respected. On JPY,
1500is ¥1,500, not ¥15.00. Three-decimal currencies (KWD, BHD) also work. - Document dates are calendar dates,
YYYY-MM-DD, with no timezone. An invoice issued on 31 March stays 31 March in Auckland and in Los Angeles. Technical timestamps (created_at) are epoch milliseconds. - Tax rates are percentages:
21,9,5.5. - Document language is per document:
enrodefresitnlpl.
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
}'
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.
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
| Status | Means |
|---|---|
draft | No number yet. Fully editable, deletable. |
open | Issued, not sent. |
sent / viewed | Emailed; viewed once the customer opened the link. |
partial / paid | Derived from recorded payments, not set by hand. |
overdue | Past the due date, unpaid. Set by a job, not only when you look. |
void | Voided. Keeps its number. |
accepted / declined / expired | Quotes 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
}
}
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" }39;
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.
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));
}
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
| Event | Fires when |
|---|---|
document.created | A draft or issued document was created. |
document.issued | A document received its number and became final. |
document.sent | A document was emailed to the customer. |
document.viewed | The customer opened the document link for the first time. |
document.paid | A document became fully paid. |
document.partially_paid | A payment was recorded but a balance remains. |
document.overdue | A document passed its due date unpaid. |
document.voided | A document was voided. |
payment.recorded | A payment was recorded, by hand or from a card. |
payment.removed | A recorded payment was deleted. |
estimate.accepted | The customer accepted a quote. |
estimate.declined | The customer declined a quote. |
customer.created | A customer was created. |
customer.updated | A customer was changed. |
expense.created | An expense was recorded. |
mailbox.provisioned | A mailbox became active on the mail server. |
domain.verified | A mail domain passed its DNS checks. |
fiscal.accepted | The tax authority accepted an e-invoice. |
fiscal.rejected | The 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:
- No ordering guarantee on webhooks. See above.
- Idempotency keys live 24 hours. A retry a week later creates a new document.
- Batches are 50 items, calculations 200 lines, list pages 200 rows.
- Mailbox passwords cannot be read back, by us or by you. Generate a new one instead.
- Numbering cannot go backwards.
nextcan be raised, never lowered — a number already issued must not be issued twice. - We do not file tax returns and do not hold your signing certificate. The e-invoicing endpoints hand documents to a provider; the legal obligations stay with you.
X-Request-Id of a call that shows the problem — that is usually enough for us to reproduce it.