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.

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"
    }
  ]
}

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.

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.

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 visiblefailed 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
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.
cost_center_iduuid · nullThe 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_namestring · nullThe 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_iduuid · nullThe document this one reverses, where known.

Header and money

FieldTypeNotes
vendor_namestring · nullThe counterparty. Extracted, canonicalised against the company's vendor list, then corrected by the reviewer where needed.
vendor_tax_idstring · nullTax registration number as printed.
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 (item + unit). 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.

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