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

StatusdetailCause
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"
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

FieldRequiredNotes
fileYes PDF, JPEG, PNG, TIFF, HEIC or HEIF. Images are converted to PDF on receipt. Maximum 50 MB.
companyYes The company slug. Case and surrounding whitespace are ignored. Must belong to your key's organization.
document_categoryNo sales, purchase, asset or expense. Declare it when your system already knows — it is the field that decides which side of the books the document lands on, and sending it is what sets classification_source to api.
document_kindNo invoice (recognises a transaction) or receipt (settles one).
settlement_methodNo credit, cash_bank or cash_cashier.
is_returnNo true for a credit note or refund. Amounts stay positive either way — see Returns do not carry negative amounts.

Declaring what you are sending

All four classification fields are optional and omitting them all is exactly the previous behaviour. But a document nobody has classified is stored as undecided, and an undecided document cannot be marked reviewed — so it will sit in the queue until a human opens it and chooses. If your system knows what it is sending, say so and skip that step.

A document_kind or settlement_method on its own does not count as a decision: neither says whether the document is a sale or a purchase. Only document_category promotes the document to classification_source: "api".

A declaration is trusted over what the extractor reads, with one exception: if it contradicts the book's own tax number printed on the document, the conflict is recorded and a human decides. Nothing is silently overridden in either direction.

Sending the same file twice

Ingest is idempotent on file content. The raw bytes are hashed on receipt, and a repeat of the same bytes for the same company returns 202 with the original invoice_id and "duplicate": true. No second document is created, no second quota slot is spent and no second extraction runs. Retrying a request that timed out is therefore safe.

The response also echoes document_kind, document_category and classification_source, so you can confirm your declaration was stored as you intended.

Responses

StatusMeaningWhat 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",
      "tax_id": "1234567-601"
    }
  ]
}

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",
  "tax_id": "1234567-601"
}

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.

tax_id is the book's own identity number as printed on the documents it issues — not vat_number, and not the counterparty's. It is what a document's printed numbers are matched against to tell a sale from a purchase, so a company without one classifies its own invoices less reliably. Up to 64 characters, and returned by GET /pull/companies. Stored only when the company has none yet: a value already set here was a person's identity decision, and a re-sync never overwrites it.

Partners

POST/pull/vendors

Mirror your supplier list into one company's partner registry, so extraction canonicalises against names you already keep rather than learning them one scan at a time. Optional — the registry fills itself from documents either way.

{
  "company": "vbacc0107",
  "force": false,
  "vendors": [
    {
      "external_id": "SUP-00412",
      "name": "EMRAD ELECTRIC SARL",
      "tax_id": "1234567-601",
      "email": "accounts@emrad.example",
      "phone": "+961 1 123 456"
    }
  ]
}
{
  "total_rows": 1,
  "created": 1,
  "updated": 0,
  "unchanged": 0,
  "failed": 0,
  "dry_run": false,
  "results": [
    { "row": 1, "name": "EMRAD ELECTRIC SARL", "status": "created", "detail": null }
  ]
}

Which partner a row means

Each row is matched against the company's existing partners in this order. The first key that hits wins, and the later keys are then not consulted — so a row carrying an external_id you have used before lands on that partner even if its name or tax ID now says otherwise, which is the point of sending one.

OrderKeyNotes
1 external_id The id the partner has in your system. The strongest key, because a rename on either side cannot move it. Unique per company; send it and nothing else can go wrong.
2 tax_id Exact match on the stored string. Not unique — extraction writes this field, so a misread must never fail a scan — and where two rows share one, the oldest wins.
3 normalised name Case-folded, whitespace-collapsed. The last resort, and the one that mis-fires: two branches of one supplier written identically are one partner here.

Nothing matched means a partner is created. Something matched means blank columns are filled in and everything already stored is left alone, so pushing your export on a schedule never reaches over a correction a bookkeeper made here. Send force: true to overwrite instead — deliberately per request, never the default.

The call is per row, not all-or-nothing: every row comes back with a status of created, updated, unchanged or error, and a row that cannot be applied is reported while the rest still land. Two rows resolving to the same partner make the second an error rather than silently applying both, so the report always says which value is stored. At most 1000 partners per request.

A 200 with failed rows in it is the normal shape of a partly-bad batch — read failed, not just the status code. Scoping is the same as every read: a company your key cannot reach answers 404 and nothing is written.

The same list as a spreadsheet

The web app exports the roster to .xlsx and reads the same file back through the same code as this endpoint, so the columns below are exactly the fields above. The export includes ID; keeping it there is what makes export → fix a spelling → re-import reach the partner it came from rather than add a second one. Applying that spelling is still an overwrite, so it needs the same force this endpoint takes — in the web app, the tick box on the preview.

ColumnFieldNotes
ID id Our uuid. Present in the export, honoured on import, and the reason a round trip cannot duplicate. Leave it blank on a row you are adding. A uuid belonging to another company is refused, never treated as a new partner.
Partner name Required. Up to 255 characters. A blank one is the one error every batch eventually hits.
Tax ID tax_id Up to 64 characters.
External ID external_id Up to 64 characters. Your own key — worth filling in even if you never import again.
Address address Up to 500 characters.
Email email Up to 255 characters.
Phone phone Up to 120 characters.
Notes notes Up to 2000 characters.

Items

POST/pull/items

Mirror your item master into one company, together with what each partner calls each item. Once a partner's spelling is linked, every line that partner prints under it resolves to your code — served on the invoice line as item_code, with the partner's own item_number still posted untouched. Optional: the master can also be built one line at a time in the review screen.

{
  "company": "vbacc0107",
  "force": false,
  "items": [
    {
      "code": "CB-12",
      "name": "Chocolate box 12pc",
      "base_unit": "box",
      "aliases": [
        { "alias": "Boîte 12", "partner": "SUP-00412" },
        { "alias": "CHOCBOX12" }
      ]
    }
  ]
}
{
  "total_rows": 3,
  "created": 3,
  "updated": 0,
  "unchanged": 0,
  "failed": 0,
  "dry_run": false,
  "results": [
    { "row": 1, "sheet": "Items",   "name": "CB-12",            "status": "created", "detail": null },
    { "row": 1, "sheet": "Aliases", "name": "CB-12 ← Boîte 12", "status": "created", "detail": null },
    { "row": 2, "sheet": "Aliases", "name": "CB-12 ← CHOCBOX12","status": "created", "detail": null }
  ]
}
FieldTypeNotes
code string Required. Up to 64 characters. Your own item code; the key when no id is sent. Case and spacing are ignored when matching.
name string Required on a new item, up to 255 characters. On an existing item it is applied only under force.
base_unit string · null Up to 40 characters. What the item is tracked in. Filled when blank here, otherwise left alone unless force.
notes string · null Up to 2000 characters. Same fill-blanks rule.
is_active boolean · null Omit to leave it alone. Applied only under force on an existing item.
aliases array What partners call this item — see below. At most 200 per item.
aliases[].alias string Required. The spelling as printed: the partner's item number when their document shows one, else their description. Matched case-insensitively with spacing collapsed.
aliases[].partner string · null Which partner prints it: their external_id, tax ID, name, or one of their other names — first hit wins. Omit for a spelling anyone may use; a partner's own alias beats the company-wide one for the same text.

An item is matched by our id when you send it back, else by code. Nothing matched means it is created. Something matched means blank columns are filled in and everything already stored is left alone — the same contract as partners — and force: true overwrites the name, the flag and the code instead.

Aliases are applied after the items, so an alias may name a code that is new on the same request. An alias that already points at a different item is an error row naming that item, and is moved only under force; one that already points here is unchanged. The report is per row: each result carries a sheet of Items or Aliases so a row number means something. At most 1000 items and 200 aliases per item per request.

The same list as a spreadsheet

The web app exports the master to an .xlsx with two sheets and reads the same file back through the same code as this endpoint. Same keys, same force, same preview before anything is written.

ColumnFieldNotes
Items · ID id Our uuid. Present in the export, honoured on import; leave it blank on a row you are adding.
Items · Code code Required.
Items · Name name Required on a new item.
Items · Base Unit base_unit
Items · Active is_active yes or no; blank leaves it alone.
Items · Notes notes
Aliases · Item Code item_code Required. May be a code that is new on the Items sheet of the same file — that sheet is applied first.
Aliases · Partner partner Blank for company-wide; else the partner's name as shown under Partners, or its tax ID or external ID.
Aliases · Alias alias Required. The spelling as printed.

Unit conversions

POST/pull/unit-conversions

Mirror the rules that turn an invoiced unit into the unit you track — fifty big bags into a thousand small ones, a case of chocolate into twelve bars. When a line matches a rule, the converted quantity is served beside the original as base_quantity and base_unit; quantity and unit_price are never touched.

{
  "company": "vbacc0107",
  "force": false,
  "conversions": [
    { "partner": "SUP-00412", "item": "Rice", "from_unit": "Big Bag",
      "factor": "20", "base_unit": "Small Bag" },
    { "from_item_code": "CB-12", "factor": "12", "base_unit": "pc",
      "to_item_code": "CHOC-1" }
  ]
}
{
  "total_rows": 2,
  "created": 1,
  "updated": 0,
  "unchanged": 1,
  "failed": 0,
  "dry_run": false,
  "results": [
    { "row": 1, "name": "Rice (Big Bag)",  "status": "created",   "detail": null },
    { "row": 2, "name": "CB-12 (any unit)", "status": "unchanged",
      "detail": "differs in factor; overwrite to apply" }
  ]
}
FieldTypeNotes
partner string · null external_id, tax ID, name or other name of the partner this rule is for. Omit for all partners; a partner's rule beats the company-wide one for the same key.
item string · null The text the partner prints — their item number when the document shows one, else their description. Required unless from_item_code is sent; with both, this is only the label the rules table shows.
from_item_code string · null One of your own item codes. The rule then fires for every alias of that item, for every partner in scope, and is matched on the item rather than on text.
from_unit string · null Up to 40 characters. Omit to match whatever unit the line printed, including none — which is most lines.
factor decimal string Required, greater than zero. How many base units are in one from-unit. Sent as a string like every other number here.
base_unit string Required, up to 40 characters.
to_item_code string · null The master item the converted quantity is a quantity of, when breaking the unit down changes what the thing is — a case of chocolate into the bar inside it. Omit when the rule only converts the unit.

A rule is matched on its key: the partner (or none), then either item or from_item_code, then from_unit. A rule has no blanks to fill — every field is the rule — so one that already exists with different values is reported as unchanged with a detail naming what differs, and is rewritten only under force. A code that names no item is an error row; push the items first. At most 1000 rules per request.

The same list as a spreadsheet

The web app exports the rules to one sheet and reads the same file back through the same code as this endpoint. Partners and items appear by name and code, not by id.

ColumnFieldNotes
ID id Our uuid. Present in the export, honoured on import; leave it blank on a row you are adding.
Partner partner Blank for all partners; else the partner's name as shown under Partners, or its tax ID or external ID.
Item item The text the partner prints. Required unless From Item Code is filled.
From Item Code from_item_code One of your codes from the Items list.
From Unit from_unit Blank means any unit.
Factor factor Required. A number, kept numeric in the sheet.
Base Unit base_unit Required.
To Item Code to_item_code Optional.

Reading invoices

GET/pull/invoices

Incremental, cursor-paginated read for one company.

ParameterDefaultNotes
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.

Telling us it reached your books

POST/pull/invoices/{id}/posted

Call this once you have written the document into your own system. Until you do, we have no way to know: /pull is read-only and the only other feedback route reports a failure, so a document you posted an hour ago looks here exactly like one nobody has touched.

Send the identifier it got on your side — a slip or voucher number in external_id, and optionally where it landed in external_ref. Both are free text; they are for a human reconciling the two systems, not for us to parse.

It is idempotent per consumer: acking twice updates your own record rather than writing a second one, so retrying after a network failure is safe. Two consumers of the same organization keep separate records and never overwrite each other.

What it deliberately does not do: it does not change the document's status, and it does not remove it from your feed. A document you have posted must stay correctable — correcting one means it is edited here, which moves it forward in your feed so you can replace your voucher.

While any consumer holds a delivery record, deleting the document here is refused, because that would leave a voucher nothing can reverse.

DELETE/pull/invoices/{id}/posted

Withdraw your own record when the voucher no longer exists — you reversed or deleted it on your side. Without this the record would go stale and keep asserting something untrue. You can only withdraw your own; withdrawing one that is not there succeeds, so retries are safe.

What your key is allowed to receive

A key can be scoped to the document types your integration can actually represent. By default a key is unscoped and receives everything, exactly as before. When a scope is set, documents outside it are simply not served — they are not errors, and they are not lost; they stay in the workspace and the review page shows them as held from your integration, so nothing is withheld silently.

Scopes restrict by kinds, categories and returns. A dimension you do not mention is not restricted. Ask us to set one if, for example, your ledger cannot express a credit note or a payment receipt — that is far safer than receiving one and posting it as an ordinary bill.

Documents with status parked are never served to any consumer. Parked is how a workspace marks paper that must not be booked at all — a proforma, a quotation, a delivery note, a statement, a duplicate. Requesting status=parked is refused rather than returning an empty page.

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.

retryableEffectUse 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")

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

FieldTypeNotes
iduuidStable across edits. Use it as your idempotency key.
company_iduuidThe owning company.
company_slugstringThe canonical stored slug. Lookup ignores case and surrounding whitespace, so this may differ from the string you sent.
statusenumuploaded · processing · extracted · failed · reviewed · pushed · parked. A parked document is paper a reviewer marked as not for the books — a proforma, a quote, a duplicate. It is never served to any consumer and asking for it is refused, so you will not encounter this value in a pull; it is listed so a switch over the enum is exhaustive.
created_viaenummobile · web · watcher. Provenance only, no accounting meaning.
created_atISO-8601Creation timestamp.
updated_atISO-8601Drives incremental sync. This is your watermark.

Classification

FieldValuesMeaning
document_kindinvoice · receiptAn 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_categorysales · purchase · asset · expenseSelects the control account and the profit-and-loss or asset account.
settlement_methodcredit · cash_bank · cash_cashierOn 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_returnbooleanOn an invoice, a credit note. On a receipt, a refund.
document_typeinvoice · credit_note · receipt · refundThe four keys above in one word, derived from document_kind and is_return. Convenience only — the keys remain authoritative.
classification_sourcedefault · ocr · rule_name · rule_tax · upload · api · humanWHO decided the classification. 'human' is a reviewer, 'api'/'upload' a declaration by whoever sent the file, 'rule_tax'/'rule_name' a match against the book's own identity, 'ocr' read off the paper. 'default' means nobody decided — such a document cannot be marked reviewed, so you will not normally see it in a reviewed feed.
counterparty{ role, name, tax_id }The party the voucher books against, with the sales/purchase switch already applied: role is 'customer' on a sale and 'vendor' otherwise. Prefer this over re-deriving it from document_category — getting that wrong books a sale against the letterhead issuer, which on a sale is the book's own company.
labelsarray of { label, text }Stamps read off the paper. label is one of exactly six values — PAID, RECEIVED, APPROVED, CANCELLED, COPY, OTHER — and anything unreadable arrives as OTHER, so a switch over those six is exhaustive. text is the ink as printed, in any language. Display only: settlement_method stays the posting fact, and a PAID stamp never turns a credit purchase into a cash one.
user_labelsarray of stringThe same six values, put on the document by a person in the web app rather than read off the ink — e.g. ["PAID"]. A separate key because it is a separate claim: labels is what the paper says, user_labels is what someone in the client's office says about it. Equally display-only, and it can appear or disappear without updated_at changing, so do not drive incremental sync off it.
reviewed_atISO-8601 · nullWhen a human approved this document. Null on anything not yet reviewed.
cost_center_iduuid · nullThe cost centre or warehouse 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. A company may allocate to a warehouse instead, meaning where the goods went rather than whose budget paid; both arrive in this one field, so read cost_center_kind before mapping it. A document carries exactly one, so a delivery split across two warehouses is not representable. Null means unallocated.
cost_center_namestring · nullThe centre's or warehouse's name as it stood when the document was reviewed. Snapshotted deliberately, so a document keeps its allocation legible after it is renamed or retired. Display it; match on cost_center_id.
cost_center_kindcost_center · warehouse · nullWhich of the two dimensions cost_center_id names: 'cost_center' is whose budget paid, 'warehouse' is where the goods went. Snapshotted with the name. Map only 'cost_center' onto your ledger's cost-centre, department or business-unit dimension — a warehouse is a location and posting it there mixes the two. Null means unallocated.
return_of_iduuid · nullThe document this one reverses, where known.

Header and money

FieldTypeNotes
vendor_namestring · nullThe letterhead issuer — the counterparty of purchase, expense and asset documents. On a sales document this is the selling company itself; the counterparty is customer_name. Extracted, canonicalised against the company's vendor list, then corrected by the reviewer where needed.
vendor_tax_idstring · nullThe issuer's tax registration number as printed.
customer_namestring · nullThe "Bill To:" recipient — the counterparty of a sales document. Book the receivable against this party, never against vendor_name. Null on documents that name no recipient, and on documents extracted before the field existed.
customer_tax_idstring · nullThe recipient's tax registration number, when printed inside the "Bill To:" block.
invoice_numberstring · nullMay be system-generated when the document showed none.
descriptionstring · nullWhat 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_datedate · nullFormat YYYY-MM-DD.
due_datedate · nullFormat YYYY-MM-DD.
currencystring · nullIntended 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.
subtotaldecimal string · nullPre-tax net.
tax_totaldecimal string · nullTax amount.
grand_totaldecimal string · nullTax-inclusive total.
discount_totaldecimal string · nullSigned. 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.

FieldTypeNotes
line_nointegerNot guaranteed unique or gapless.
item_numberstring · nullItem or job code.
descriptionstring · nullFree text.
quantitydecimal string · nullFour decimal places.
unitstring · nullUnit of measure as captured, e.g. "Big Bag", "Carton". Null when the document did not state one.
unit_pricedecimal string · nullFour decimal places.
discount_pctdecimal string · nullFour decimal places, capped at 99.9999. Ambiguous unit — see pitfalls.
tax_ratedecimal string · nullFour decimal places, capped at 99.9999. Ambiguous unit — see pitfalls.
line_totaldecimal string · nullPre-tax, two decimal places. The invoice-level discount is never baked in.
line_currencystring · nullUsually null. Null means the line uses the invoice-level currency — the API does not substitute it, so apply that fallback yourself.
base_unitstring · nullBase unit the line converts to when a per-company unit conversion matched. A rule is matched on the item, optionally narrowed to one partner and one from-unit; a rule with no from-unit matches the item whatever unit the line printed, including none. Null when no rule applies.
base_quantitydecimal string · nullConverted 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.
base_item_numberstring · nullCode of the master item base_quantity is a quantity of, when the matched rule names one — a case of chocolate converting to the bar inside it. Null when the rule only converts the unit, which is the common case.
base_item_descriptionstring · nullThat master item's name, snapshotted on the line so it still reads correctly after the master is renamed or deleted.
item_iduuid · nullThe company's own master item this line resolved to, via an alias recorded for this invoice's partner. Null when nothing matched, which is the normal case until someone links the line.
item_codestring · nullThat master item's code, snapshotted on the line so it still reads correctly after the master is renamed or deleted. Never a substitute for item_number — see below.

item_number is never rewritten

item_number is exactly what the document said, and it is what you post against. Linking a line to a master item adds item_id and item_code beside it and changes nothing about it — not the spelling, not the case, not the spacing. Rewriting it would silently re-point historic vouchers at a different stock item, so we do not.

Use item_code when you want the buyer's own catalogue code for a line, and item_number when you want what the supplier printed. They are different questions and both are answered.

Conversions never touch quantity or unit_price

quantity and unit_price are exactly as captured, and quantity × unit_price still reconciles against line_total and the invoice total. A unit conversion writes its result into base_unit, base_quantity, base_item_number and base_item_description and touches nothing else. Converting the quantity without the price would leave a voucher that balances in total while every per-item amount on it is wrong, so we do not.

Post against quantity and unit_price. Treat the base_* fields as a stock-keeping view of the same line, never as a second set of amounts.

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. The counterparty depends on direction: on a sales document book against customer_name; on everything else book against vendor_name.

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 GET and POST /pull/companies share a single 120/min allowance between them because they are the same path.
  • No Retry-After and no X-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.

PitfallWhat 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.
A unit conversion resolves exactly one hop and never chains.If a rule converts a case to a box and another rule converts that box to a bar, a case line reports boxes — not bars. base_quantity is one factor applied once, so do not re-apply your own second conversion on top of it.
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.
A PAID label is not a payment.Neither labels nor user_labels affects how a document books. A purchase stamped PAID stays settlement_method: credit until someone records the settlement, and posting it as cash on the strength of the stamp puts money through an account it never went through. Read settlement_method; treat both label fields as annotation.

Something unclear or missing? Tell us — this page is maintained against the running API.