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. |
document_category | No | 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_kind | No | invoice (recognises a transaction) or receipt
(settles one).
|
settlement_method | No | credit, cash_bank or cash_cashier. |
is_return | No | 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
| 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",
"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.
| Order | Key | Notes |
|---|---|---|
| 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.
| Column | Field | Notes |
|---|---|---|
| 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 | 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 }
]
} | Field | Type | Notes |
|---|---|---|
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.
| Column | Field | Notes |
|---|---|---|
| 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" }
]
} | Field | Type | Notes |
|---|---|---|
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.
| Column | Field | Notes |
|---|---|---|
| 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.
| 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.
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.
| 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 · 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_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. |
document_type | invoice · credit_note · receipt · refund | The four keys above in one word, derived from document_kind and is_return. Convenience only — the keys remain authoritative. |
classification_source | default · ocr · rule_name · rule_tax · upload · api · human | WHO 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. |
labels | array 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_labels | array of string | The 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_at | ISO-8601 · null | When a human approved this document. Null on anything not yet reviewed. |
cost_center_id | uuid · null | The 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_name | string · null | The 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_kind | cost_center · warehouse · null | Which 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_id | uuid · null | The document this one reverses, where known. |
Header and money
| Field | Type | Notes |
|---|---|---|
vendor_name | string · null | The 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_id | string · null | The issuer's tax registration number as printed. |
customer_name | string · null | The "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_id | string · null | The recipient's tax registration number, when printed inside the "Bill To:" block. |
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. 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_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. |
base_item_number | string · null | Code 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_description | string · null | That master item's name, snapshotted on the line so it still reads correctly after the master is renamed or deleted. |
item_id | uuid · null | The 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_code | string · null | That 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
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. |
| 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.