Developers / Reference
API reference
Everything an API key reaches: push documents in, pull reviewed invoices out with their general-ledger coding already resolved.
Base URL & conventions
- Base URL
https://invoices.coresrp.com- Transport
-
HTTPS only, with HSTS enforced. Plain HTTP gets a 301, and most HTTP clients silently
turn a redirected POST into a GET — always call
https://directly rather than relying on the redirect. - Read timeout
- 120 seconds server-side. Set your client at or below that. Separately, an upload must keep bytes flowing — a stall of more than 10 seconds mid-body is dropped, which matters on slow links with large scans.
- Concurrency
- Maximum 20 simultaneous connections per IP address.
- Region
- AWS eu-central-1.
Money is transmitted as strings
Every amount, quantity and rate is a JSON string such as "1100.00",
never a number. Parse them with a decimal type. Parsing as a float will eventually
misstate a voucher by a cent, which in accounting is a reconciliation failure rather than
a rounding artefact.
Correlate your requests
Send an X-Request-ID header on every call. We honour the value you send, echo
it back on the response, and stamp it on every server-side log line for that request. Log
it alongside the response status and we can trace any individual failure directly.
Authentication
One bearer token on every request. There is no OAuth flow, no refresh, and no expiry.
Authorization: Bearer ci_<prefix>_<secret>
The token is the literal prefix ci_, an 8-character lookup prefix, and a
32-character secret. Only the lookup prefix is stored; the secret is held as an Argon2
hash, so a lost key cannot be recovered — only replaced.
Keys are organization-scoped
A key reaches every company in its organization and can create more. There is no per-company or per-endpoint restriction, which is why each request names its company by slug. If you need hard isolation between two parties, they need separate organizations — separate keys within one organization isolate nothing.
Keys never expire. The only lifecycle event is revocation, done from the same settings screen, and it takes effect on the key's next use — there is no grace period and no cache to wait out. Rotating means issuing a new key, deploying it, then revoking the old one.
Rejection cases
| Status | detail | Cause |
|---|---|---|
| 401 | missing bearer token | No Authorization header, or the scheme is not Bearer. |
| 401 | not an api key | A token was sent but does not start with ci_ — usually a user JWT by mistake. |
| 401 | invalid api key | Unknown prefix, wrong secret, or the key has been revoked. |
Uploading documents
POST/ingest/invoices
A multipart/form-data request with two parts: the file, and the company it
belongs to. The document enters the extraction queue, and becomes available on the pull
side once a person has reviewed it.
curl -X POST https://invoices.coresrp.com/ingest/invoices \
-H "Authorization: Bearer $CI_API_KEY" \
-H "X-Request-ID: $(uuidgen)" \
-F "company=vbacc0107" \
-F "file=@invoice.pdf;type=application/pdf" import os, uuid, requests
API = "https://invoices.coresrp.com"
KEY = os.environ["CI_API_KEY"]
def upload(company: str, path: str) -> str:
with open(path, "rb") as fh:
response = requests.post(
f"{API}/ingest/invoices",
headers={
"Authorization": f"Bearer {KEY}",
"X-Request-ID": uuid.uuid4().hex,
},
data={"company": company},
files={"file": (os.path.basename(path), fh, "application/pdf")},
timeout=120,
)
if response.status_code == 402:
d = response.json()["detail"]
raise SystemExit(
f"Quota exhausted: {d['invoices_created']}/{d['invoice_quota']}"
)
response.raise_for_status()
return response.json()["invoice_id"] import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { randomUUID } from "node:crypto";
const API = "https://invoices.coresrp.com";
const KEY = process.env.CI_API_KEY;
export async function upload(company, path) {
const form = new FormData();
form.set("company", company);
form.set(
"file",
new Blob([await readFile(path)], { type: "application/pdf" }),
basename(path),
);
const res = await fetch(API + "/ingest/invoices", {
method: "POST",
headers: { Authorization: "Bearer " + KEY, "X-Request-ID": randomUUID() },
body: form,
});
if (res.status === 402) {
const { detail } = await res.json();
throw new Error(
"Quota exhausted: " + detail.invoices_created + "/" + detail.invoice_quota,
);
}
if (!res.ok) throw new Error("HTTP " + res.status + ": " + (await res.text()));
const { invoice_id } = await res.json();
return invoice_id;
} using System.Net.Http.Headers;
using System.Text.Json;
var api = "https://invoices.coresrp.com";
var key = Environment.GetEnvironmentVariable("CI_API_KEY")!;
using var http = new HttpClient
{
BaseAddress = new Uri(api),
Timeout = TimeSpan.FromSeconds(120),
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", key);
async Task<string> UploadAsync(string company, string path)
{
using var form = new MultipartFormDataContent();
form.Add(new StringContent(company), "company");
var file = new StreamContent(File.OpenRead(path));
file.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
form.Add(file, "file", Path.GetFileName(path));
using var request =
new HttpRequestMessage(HttpMethod.Post, "/ingest/invoices") { Content = form };
request.Headers.Add("X-Request-ID", Guid.NewGuid().ToString("N"));
using var response = await http.SendAsync(request);
if ((int)response.StatusCode == 402)
throw new InvalidOperationException("Invoice quota exhausted - stop uploading.");
response.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
return doc.RootElement.GetProperty("invoice_id").GetString()!;
} HTTP/1.1 202 Accepted
{
"invoice_id": "01a02806-feea-7c33-b89f-390b59c0950f",
"status": "processing"
}
A 202 means the bytes are stored and extraction is queued — not that
extraction succeeded. It is your signal to move the file out of the outbound folder.
Request fields
| Field | Required | Notes |
|---|---|---|
file | Yes | PDF, JPEG, PNG, TIFF, HEIC or HEIF. Images are converted to PDF on receipt. Maximum 50 MB. |
company | Yes | The company slug. Case and surrounding whitespace are ignored. Must belong to your key's organization. |
Responses
| Status | Meaning | What to do |
|---|---|---|
| 202 | Accepted and queued for extraction. | Move the file to your processed folder. |
| 400 | The uploaded file was empty. | Skip it. Do not retry. |
| 402 | Invoice quota exhausted. | Stop and alert an operator. Retrying never succeeds. |
| 404 | Company slug not found in your organization. | Fix the slug, or register the company first. |
| 413 | Over 50 MB, or the scan rasterises too large. | Read the detail first: a megapixel limit means downsample and retry; a page-count limit means split the file instead — downsampling will not clear it. |
| 415 | Unsupported content type. | Skip it. Do not retry. Note image/jpg is NOT accepted — it must be image/jpeg. |
| 422 | A required form field is missing or malformed. | Almost always a missing company or file part. Fix the request. |
| 429 | Rate limited. | Back off exponentially and retry. |
Quota
Uploading is the only direction that consumes quota, and the counter is
monotonic — deleting an invoice does not return the slot. Treat
402 as a stop condition rather than a retryable error.
One upload does not always mean one slot. When extraction finds several invoices inside a
single document — a multi-page scan of separate bills, for instance — each additional
invoice claims another slot after the 202 has already been returned.
For the same reason, one invoice_id in the response can become several
documents on the pull side.
If the quota runs out partway through such a document, it is failed as a whole and the
reason is recorded on the invoice for a reviewer. You will not receive a
402 for it — the 202 was returned long before.
HTTP/1.1 402 Payment Required
{
"detail": {
"code": "quota_exceeded",
"plan": "free",
"invoice_quota": 10,
"invoices_created": 10
}
} Companies
GET/pull/companies
Discover what your key can reach. Drive your integration from this list rather than a hard-coded set, so that adding a company in the web app is the only step needed to start syncing it.
{
"items": [
{
"id": "6f1c8e2a-...",
"slug": "vbacc0107",
"name": "VITANEST",
"vat_registered": true,
"vat_registered_from": "2026-01-01"
}
]
} POST/pull/companies
Register a company that exists in your system but not yet in ours. Optional — only needed when your side is the source of truth for which books exist.
{
"slug": "vbacc0098",
"name": "SPRINT GATES SAL",
"vat_registered": true,
"vat_number": "1234567-601",
"vat_registered_from": "2026-01-01"
}
Returns 200 (not 201) with
id, slug, name, vat_registered and a
created boolean. It is create-only and idempotent: an
existing slug is returned untouched, so a re-sync never overwrites a name or VAT setting
edited in the web app. Safe to call on every cycle.
The slug is lower-cased and trimmed before both lookup and insert, so
VBACC0098 and vbacc0098 are the same company. Length limits are
80 characters for slug, 120 for name and 32 for
vat_number; a blank or whitespace-only name is rejected. A company created
this way is seeded with the default chart of accounts, so it can be synced immediately.
Reading invoices
GET/pull/invoices
Incremental, cursor-paginated read for one company.
| Parameter | Default | Notes |
|---|---|---|
company | — | Required. The company slug. |
status | reviewed | Only documents in this status. reviewed means a person has approved the extraction. |
updated_since | — | ISO-8601. Returns documents with updated_at >= this value. Inclusive. |
cursor | — | Opaque keyset cursor from the previous page. |
limit | 100 | Between 1 and 200. |
curl -G https://invoices.coresrp.com/pull/invoices \
-H "Authorization: Bearer $CI_API_KEY" \
--data-urlencode "company=vbacc0107" \
--data-urlencode "updated_since=2026-08-01T00:00:00Z" \
--data-urlencode "limit=100" {
"items": [ /* invoice objects */ ],
"next_cursor": "MjAyNi0wOC0yMlQxMDoxNDowMCswMDowMHwwMWEw",
"server_time": "2026-08-22T10:31:07.884210+00:00"
}
Paging is keyset-ordered on (updated_at, id), so no document is ever
skipped across pages. Follow next_cursor until it returns
null.
A document can still be duplicated: editing an invoice mid-pass moves its
updated_at forward, so a row you already received can sort into a later page
and arrive twice. That is one more reason deduplicating on id is mandatory
rather than defensive.
Soft-deleted invoices are excluded automatically, and there is no tombstone — a deleted document simply stops appearing. The same is true of one whose status changes away from the value you filter on. If your books need to react to a withdrawal, you cannot detect it from this feed alone.
Reporting failures
POST/pull/invoices/{invoice_id}/error
Tell us a document could not be posted. Without this call, a failure on your side is invisible to the reviewer — they see an invoice sitting in reviewed that never reached the books.
{
"code": "mapping_error",
"message": "no GL mapping for purchase / cash_bank",
"retryable": false
} code is capped at 64 characters and message at 4000; longer
values are rejected, so truncate on your side. The two are stored combined as
[erp:<code>] <message>, so the reviewer sees your code inline. The
call returns 200 with id, status and
error_message — the returned status is the authoritative answer
to whether the document was actually demoted.
| retryable | Effect | Use for |
|---|---|---|
false | Records the message, and moves the invoice to failed only if it is currently reviewed. It then leaves the reviewed set, so your watermark can move past it. A document in any other status — notably one already pushed — keeps its status and just receives the message. | Data faults a person must fix: missing GL mapping, totals that do not reconcile, a payload your schema rejects. |
true | Records the message only. Status is untouched, so the invoice keeps coming back. | Transient faults: your database was down, the network dropped. |
Marking a failure non-retryable is what makes it visible — failed is a
state the invoice list filters on, whereas the error message alone appears in no list view.
To requeue a failed document, the reviewer fixes it and marks it reviewed again, which
clears the error and bumps updated_at so your next poll picks it up.
The sync loop
This is the part that is easy to get subtly wrong. The shape below is what our own on-premise sync service runs in production.
from decimal import Decimal
import os, requests
API = "https://invoices.coresrp.com"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['CI_API_KEY']}"
def pages(company, updated_since=None):
"""Yield every invoice, following the keyset cursor to exhaustion."""
cursor = None
while True:
params = {"company": company, "limit": 100}
if updated_since:
params["updated_since"] = updated_since
if cursor:
params["cursor"] = cursor
page = session.get(f"{API}/pull/invoices", params=params, timeout=120)
page.raise_for_status()
page = page.json()
yield from page["items"]
cursor = page.get("next_cursor")
if not cursor:
return
def sync(company):
watermark = load_watermark(company) # your persistence
high_water, had_error = watermark, False
for inv in pages(company, watermark):
# updated_since is INCLUSIVE, so the boundary invoice is re-served on
# every poll. Without this check you will double-post it.
if already_posted(inv["id"]):
high_water = max(high_water or "", inv["updated_at"])
continue
try:
post_to_books(inv)
mark_posted(inv["id"])
high_water = max(high_water or "", inv["updated_at"])
except DataFault as exc:
# Moves it to 'failed', so it drops out of the reviewed set and
# the watermark can safely move past it.
session.post(
f"{API}/pull/invoices/{inv['id']}/error",
json={
"code": exc.code,
"message": str(exc)[:4000],
"retryable": False,
},
timeout=30,
)
high_water = max(high_water or "", inv["updated_at"])
except TransientFault:
had_error = True # pin the watermark; retry on the next poll
break
if not had_error and high_water != watermark:
save_watermark(company, high_water)
def money(value):
"""Amounts arrive as strings. Never parse them as float."""
return Decimal(value) if value is not None else Decimal("0") const API = "https://invoices.coresrp.com";
const auth = { Authorization: "Bearer " + process.env.CI_API_KEY };
async function* pages(company, updatedSince) {
let cursor = null;
for (;;) {
const q = new URLSearchParams({ company, limit: "100" });
if (updatedSince) q.set("updated_since", updatedSince);
if (cursor) q.set("cursor", cursor);
const res = await fetch(API + "/pull/invoices?" + q, { headers: auth });
if (!res.ok) throw new Error("HTTP " + res.status);
const page = await res.json();
yield* page.items;
cursor = page.next_cursor;
if (!cursor) return;
}
}
export async function sync(company) {
const watermark = await loadWatermark(company);
let highWater = watermark;
let hadError = false;
const advance = (ts) => {
if (!highWater || ts > highWater) highWater = ts;
};
for await (const inv of pages(company, watermark)) {
// updated_since is INCLUSIVE - the boundary invoice comes back on every
// poll, so skipping what you already posted is mandatory.
if (await alreadyPosted(inv.id)) {
advance(inv.updated_at);
continue;
}
try {
await postToBooks(inv);
await markPosted(inv.id);
advance(inv.updated_at);
} catch (err) {
if (err.transient) {
hadError = true; // pin the watermark
break;
}
await fetch(API + "/pull/invoices/" + inv.id + "/error", {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
code: err.code,
message: String(err).slice(0, 4000),
retryable: false,
}),
});
advance(inv.updated_at);
}
}
if (!hadError && highWater !== watermark) {
await saveWatermark(company, highWater);
}
}
// Amounts are strings and JavaScript numbers cannot hold them exactly.
// Keep them as strings, or use a decimal library. Never parseFloat. using System.Globalization;
using System.Text.Json;
// 'http' is the authenticated HttpClient from the upload example above.
async Task SyncAsync(string company)
{
var watermark = await LoadWatermarkAsync(company);
var highWater = watermark;
var hadError = false;
string Later(string? a, string b) =>
a is null || string.CompareOrdinal(b, a) > 0 ? b : a;
await foreach (var inv in PagesAsync(company, watermark))
{
var id = inv.GetProperty("id").GetString()!;
var updatedAt = inv.GetProperty("updated_at").GetString()!;
// updated_since is INCLUSIVE, so the boundary invoice is re-served on
// every poll. Skipping what you already posted is mandatory.
if (await AlreadyPostedAsync(id))
{
highWater = Later(highWater, updatedAt);
continue;
}
try
{
await PostToBooksAsync(inv);
await MarkPostedAsync(id);
highWater = Later(highWater, updatedAt);
}
catch (DataFaultException e)
{
// Moves it to 'failed', so it leaves the reviewed set.
await ReportErrorAsync(id, e.Code, e.Message, retryable: false);
highWater = Later(highWater, updatedAt);
}
catch (TransientException)
{
hadError = true; // pin the watermark; retry on the next poll
break;
}
}
if (!hadError && highWater != watermark)
await SaveWatermarkAsync(company, highWater);
}
async IAsyncEnumerable<JsonElement> PagesAsync(string company, string? since)
{
string? cursor = null;
while (true)
{
var q = "?company=" + Uri.EscapeDataString(company) + "&limit=100";
if (since is not null) q += "&updated_since=" + Uri.EscapeDataString(since);
if (cursor is not null) q += "&cursor=" + Uri.EscapeDataString(cursor);
using var res = await http.GetAsync("/pull/invoices" + q);
res.EnsureSuccessStatusCode();
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
foreach (var item in doc.RootElement.GetProperty("items").EnumerateArray())
yield return item.Clone();
var next = doc.RootElement.GetProperty("next_cursor");
cursor = next.ValueKind == JsonValueKind.Null ? null : next.GetString();
if (cursor is null) yield break;
}
}
// Amounts arrive as strings: parse with decimal, never double.
static decimal Money(string? v) =>
v is null ? 0m : decimal.Parse(v, CultureInfo.InvariantCulture); 1 · Deduplicate on invoice id
updated_since is inclusive, so the boundary invoice is
re-served on every poll, forever. If you do not check whether you have already posted a
document before posting it, you will double-post — reliably, not occasionally.
2 · Never advance the watermark past a failure
Your watermark is the highest updated_at you successfully handled.
If anything failed transiently during a pass, do not save it. Advancing past an errored
document silently drops it, and nothing will bring it back.
3 · Run exactly one poller per company
Deduplication alone does not make two pollers safe. The second sees a document the first has claimed, counts it as a skip, and advances the watermark past it — while the first is still mid-post. Give each worker its own watermark, or run a single poller.
Overlap your watermark slightly
updated_at is stamped at the start of the database transaction, not at
commit. A write that began just before your watermark but committed just after it can
therefore fall outside your next query and be missed. Subtracting a small margin — a
minute is ample — when you send updated_since closes the gap, and costs
nothing because you are already deduplicating on id.
Expect unchanged documents to come back
Any write to an invoice bumps updated_at and re-serves it, even when nothing
you care about changed — recording a payment against it is enough. Your deduplication
should compare what you already posted, not simply trust that a re-served document is new
or different.
Do not use server_time as your watermark
It looks like the obvious choice and it is the wrong one. It would jump past documents
that errored during the pass, and past any invoice whose updated_at moved
after its page was served. Use the maximum updated_at you actually processed.
Poll every couple of minutes; never faster than every 15 seconds. There is no webhook today — if you need one, tell us.
Invoice payload
Each element of items. Three further keys —
vat_applicable (boolean), gl_mapping (object or null) and
journal_entries (array or null) — are part of every element too; they are
documented under Accounting semantics.
Identity
| Field | Type | Notes |
|---|---|---|
id | uuid | Stable across edits. Use it as your idempotency key. |
company_id | uuid | The owning company. |
company_slug | string | The canonical stored slug. Lookup ignores case and surrounding whitespace, so this may differ from the string you sent. |
status | enum | uploaded · processing · extracted · failed · reviewed · pushed |
created_via | enum | mobile · web · watcher. Provenance only, no accounting meaning. |
created_at | ISO-8601 | Creation timestamp. |
updated_at | ISO-8601 | Drives incremental sync. This is your watermark. |
Classification
| Field | Values | Meaning |
|---|---|---|
document_kind | invoice · receipt | An invoice recognises a transaction and raises the matching receivable or payable. A receipt settles one and clears it. A receipt is not the reverse of an invoice. |
document_category | sales · purchase · asset · expense | Selects the control account and the profit-and-loss or asset account. |
settlement_method | credit · cash_bank · cash_cashier | On an invoice, whether it was settled at source and through which account. On a receipt, how the money moved — credit there means a note or post-dated cheque, not cash. |
is_return | boolean | On an invoice, a credit note. On a receipt, a refund. |
cost_center_id | uuid · null | The cost centre this document is allocated to, if the company uses them. A dimension alongside the accounts in gl_mapping, not a substitute for them — post the gl_mapping codes and treat this as the analytic axis your ledger calls a cost centre, department or business unit. Null means unallocated. |
cost_center_name | string · null | The centre's name as it stood when the document was reviewed. Snapshotted deliberately, so a document keeps its allocation legible after the centre is renamed or retired. Display it; match on cost_center_id. |
return_of_id | uuid · null | The document this one reverses, where known. |
Header and money
| Field | Type | Notes |
|---|---|---|
vendor_name | string · null | The counterparty. Extracted, canonicalised against the company's vendor list, then corrected by the reviewer where needed. |
vendor_tax_id | string · null | Tax registration number as printed. |
invoice_number | string · null | May be system-generated when the document showed none. |
description | string · null | What the invoice is for, in plain English. Written by the reviewer, or derived from the line items when they left it blank ("LED Panel 60x60 40W +2 more"). Capped at 255 characters. Descriptive only — it carries no accounting meaning and must not be parsed. |
invoice_date | date · null | Format YYYY-MM-DD. |
due_date | date · null | Format YYYY-MM-DD. |
currency | string · null | Intended as ISO-4217, but stored as a free 3-character string that is neither validated nor case-normalised. Match case-insensitively and fail loudly on a code you do not recognise. |
subtotal | decimal string · null | Pre-tax net. |
tax_total | decimal string · null | Tax amount. |
grand_total | decimal string · null | Tax-inclusive total. |
discount_total | decimal string · null | Signed. subtotal = sum(line_total) − discount_total, so a negative value is a surcharge. |
Line items
line_items is always an array — possibly empty, never null —
ordered by line_no.
| Field | Type | Notes |
|---|---|---|
line_no | integer | Not guaranteed unique or gapless. |
item_number | string · null | Item or job code. |
description | string · null | Free text. |
quantity | decimal string · null | Four decimal places. |
unit | string · null | Unit of measure as captured, e.g. "Big Bag", "Carton". Null when the document did not state one. |
unit_price | decimal string · null | Four decimal places. |
discount_pct | decimal string · null | Four decimal places, capped at 99.9999. Ambiguous unit — see pitfalls. |
tax_rate | decimal string · null | Four decimal places, capped at 99.9999. Ambiguous unit — see pitfalls. |
line_total | decimal string · null | Pre-tax, two decimal places. The invoice-level discount is never baked in. |
line_currency | string · null | Usually null. Null means the line uses the invoice-level currency — the API does not substitute it, so apply that fallback yourself. |
base_unit | string · null | Base unit the line converts to when a per-company unit conversion matched (item + unit). Null when no rule applies. |
base_quantity | decimal string · null | Converted quantity in base_unit — quantity × the configured factor (e.g. 50 Big Bags → "1000.0000" Small Bags). Null when no rule applies. Snapshotted at review; the line total is unchanged, so the base unit price is line_total ÷ base_quantity. |
Accounting semantics
General-ledger coding is resolved server-side against each company's own chart of accounts, so you post the codes you are given rather than deriving them.
"vat_applicable": true,
"gl_mapping": {
"debit_code": "60111", "debit_name": "Purchases of Goods Taxable VAT",
"credit_code": "4011", "credit_name": "Suppliers - Invoices",
"vat_code": "44210", "vat_name": "Input VAT (Purchases)"
},
"journal_entries": [
{ "account_code": "60111", "account_name": "Purchases of Goods Taxable VAT",
"debit": "630.63", "credit": null },
{ "account_code": "44210", "account_name": "Input VAT (Purchases)",
"debit": "69.37", "credit": null },
{ "account_code": "4011", "account_name": "Suppliers - Invoices",
"debit": null, "credit": "700.00" }
] Post from gl_mapping
gl_mapping is the source of truth for posting. It is what
our own production sync service posts from. journal_entries is a convenience
rendering of the same decision — correct and balanced, but treat it as reference data.
In journal_entries, direction is carried by which of debit or
credit is populated — exactly one is non-null on every line, and each line
also carries an account_name. Amounts are the document's own totals rendered
as fixed two-decimal strings; they are not re-signed for direction, so a document
that prints a negative total produces a negative amount here.
When the journal splits VAT into three lines, the gross sits on the counterparty account (receivable, payable, bank or cash) and the net on the profit-and-loss or asset account. A two-line journal has no split, and both legs carry the gross — do not assume the smaller amount is the net.
Both fields can be null
gl_mapping is null when the company has no mapping configured for
that exact combination of kind, category, settlement method and direction. Treat it as a
data fault, report it with retryable: false, and let someone configure the
company's accounts. journal_entries is additionally null when the
document has no grand_total, so a populated mapping alongside a null journal is
legitimate.
Account codes are per-company and editable
Defaults follow the Lebanese plan comptable — 4111 clients, 4011
suppliers, 512 bank, 53 cash, 44210 input VAT,
44270 output VAT — but every company can rename and recode its own accounts.
Read the codes from each payload; never hard-code them.
VAT
vat_applicable is true only when all three hold: the document is an invoice
rather than a receipt, tax is non-zero, and the company was VAT-registered on that
invoice's date. Honour this flag rather than re-deriving VAT from
tax_total.
Registration may carry an effective date, exposed as
vat_registered_from on GET /pull/companies. When it is set,
enabling registration does not reach back into documents dated before it, and an undated
document is treated as outside the registered period. When it is null,
registration applies to every date regardless of age.
Branch on the journal, not the flag
vat_applicable: true does not guarantee a three-line journal — a document
with tax but no subtotal is still posted at gross in two lines. To know
whether VAT was actually split, check for a line whose account_code equals
gl_mapping.vat_code.
Returns do not carry negative amounts
A credit note carries positive totals, exactly as printed. The reversal is
expressed by is_return and by a different pair of accounts. When you net
amounts for reporting, negate returns yourself.
Errors & rate limits
Errors return detail, usually a string and occasionally an object (the quota
response). Rate limiting is the exception and uses a different key:
HTTP/1.1 429 Too Many Requests
{ "error": "Rate limit exceeded: 120 per 1 minute" }
Parse both detail and error. A client that reads only
detail logs an empty message for every 429, which makes a rate-limited
integration very hard to diagnose.
Limits
60 requests per minute on ingest, and 120 per minute on the pull endpoints. Three properties matter more than the numbers:
- Budgets are keyed by source IP and request path, not by API key.
Processes behind one office NAT share a budget, rotating the key does not reset it, and
GETandPOST /pull/companiesshare a single 120/min allowance between them because they are the same path. - No
Retry-Afterand noX-RateLimit-*headers. You cannot read your remaining allowance, so implement your own backoff and apply it on receipt of a 429. - Not every 429 is JSON. A burst fast enough to trip the edge proxy is
rejected before the application runs and returns an HTML error page. The same is true of
a body over 50 MB, which produces an HTML 413 rather than
{"detail": "file too large"}. Check the content type before parsing an error body, and never assume a 4xx is JSON.
Use exponential backoff with jitter on 429 and 5xx. Do not retry
400, 402, 404 or 415 — none will succeed
on a second attempt.
Known pitfalls
Things that have caught integrations before. None are obvious from the payload alone.
| Pitfall | What to do |
|---|---|
| discount_pct and tax_rate are ambiguous between a fraction and a percentage. | Our convention treats a value ≤ 1 as a fraction (0.10 = 10%) and > 1 as a percentage (10 = 10%). Nothing distinguishes 1 = 100% from 1 = 1%. |
| subtotal + tax_total = grand_total is filled in at extraction but not re-enforced after a human edit. | Verify the arithmetic before posting. Refuse rather than post a voucher whose discrepancy hides inside a scaled unit price. |
| grand_total can legitimately be negative when the document prints a parenthesised amount. | Do not assume non-negative money. |
| Line items carry no stable identifier, and line_no is not guaranteed contiguous. | When an edited invoice is re-served, replace its lines wholesale rather than matching them one by one. |
| An invoice can be edited after you have posted it, and reappears with a newer updated_at. | Decide deliberately whether a re-served document overwrites your entry or is refused. Failing silently is the worst option. |
| Classification fields are set by a human reviewer, not by extraction. | A document approved without review carries the defaults invoice / purchase / credit, which may not describe it. |
| An unrecognised currency is easy to treat as a 1:1 rate by accident. | Fail loudly on a currency you have no rate for. A silent default posts a misstated voucher. |
Something unclear or missing? Tell us — this page is maintained against the running API.