← Commercial Intelligence Partners

API docs

Two APIs: a Gold-only ingestion API for sending data in, and a read API (any paid plan) for reading your metrics, insights and reports back out. Both are per-organisation and keyed by an API key you create in the app under Import → API access.

Authentication & scopes

Every request carries Authorization: Bearer <key>. A key has one or more scopes:

  • ingest — write to POST /api/ingest. Requires the Gold plan.
  • read — read from /api/v1/*. Available on every plan, Bronze included.

The read API also accepts a signed-in browser session (cookie) in place of an API key — the same read entitlement check applies either way. Session tokens are not accepted as a bearer token; use an API key for anything outside the browser. The org is always derived from the key or the session; it is never taken from the request body or query string.

Rate limits

Limits are per organisation and enforced durably (they survive a cold start and are shared across every serverless instance):

  • POST /api/ingest — 60 requests per 10 minutes.
  • GET /api/v1/* — 120 requests per minute per organisation, and 60 per minute per API key.

Exceeding a limit returns 429 with a Retry-After header (seconds until the window resets).

Idempotency

POST /api/ingest accepts an optional Idempotency-Key header (1–128 characters, letters/digits/_/-). Send the same key with the exact same request body within 24 hours and you get back the exact same stored response, with an Idempotent-Replayed: true header — safe to retry a request you're not sure succeeded. Reusing a key with a different body returns 422; a concurrent request still in flight for the same key returns 409.

POST /api/ingest

Two body formats, both sent as JSON (Content-Type: application/json):

CSV — a CSV file's text embedded in a small envelope:

{ "period": "2026-07", "csv": "date,type,category,amount\n2026-07-01,sale,Sales,120.00\n..." }

JSON rows (schema v1) — structured rows, optionally with your own external id for idempotent upserts:

{
  "schemaVersion": 1,
  "period": "2026-07",
  "venue": "High Street",
  "rows": [
    { "externalId": "inv-00231", "date": "2026-07-03", "type": "sale", "category": "Sales", "amount": 84.50 },
    { "date": "2026-07-04", "type": "cost", "category": "Materials", "amount": 12.00, "counterparty": "Acme Trade Supplies", "description": "Weekly order" }
  ]
}

A row with externalId is upserted idempotently (resending the same row again is a no-op; changing it updates the existing line). A row with no externalId is replaced wholesale each time you submit that period — the same "full reimport" behaviour as the CSV path, scoped to just the rows you didn't give an id for (and, if you have more than one site, to the venue below).type is one of sale, cost, wage (or a recognised synonym, e.g. revenue, expense); amount is in the business's own currency, in major units (e.g. 84.50, not 8450), up to 2 decimal places, always positive (the sign is derived from type). Amounts sent through the API (JSON rows and the csv body alike) must exclude GST or VAT: they are stored as sent, and there is no field to say they include tax. (The CSV upload on the Import page can take tax out of tax-inclusive sales for you; the API never does.) Up to 10,000 rows per request. Optional counterparty (the supplier or customer; supplier is accepted as another name for it, up to 120 characters) and description (up to 200 characters, shown when you drill into the lines) are stored with the line; longer values are shortened, never refused.

Multiple sites (Gold): both body formats accept an optional top-level venue — a site's id, or its exact name (case-insensitive). Omit it and every row goes to your default site. An unrecognised or archived site returns 400 { "error": "Unknown venue" }.

Response:

{ "ok": true, "period": "2026-07", "inserted": 1, "updated": 0, "unchanged": 0, "rejected": [] }

Example:

curl -X POST https://www.commercialintelligencepartners.co.uk/api/ingest \
  -H "Authorization: Bearer cip_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 2026-07-nightly-sync-1" \
  -d '{"schemaVersion":1,"period":"2026-07","rows":[{"externalId":"inv-1","date":"2026-07-03","type":"sale","amount":84.50}]}'

Read API — /api/v1

GET /api/v1/metrics?period=2026-07 — one period's KPIs (404 if that period has no data). Or ?from=2026-01&to=2026-07 for a range.

{
  "period": "2026-07",
  "currency": "AUD",
  "netSalesPence": 845000,
  "netSales": 8450,
  "grossProfitPence": 512000,
  "grossProfit": 5120,
  "marginPct": 60.6,
  "labourPct": 28.4
}

Currency. Every amount is in the business's own currency, named by currency (an ISO 4217 code such as GBP or AUD). Each money figure comes twice: a …Pence integer in minor units (pence or cents; the exact value) and the same amount in major units (pounds or dollars). A range response carries currency at the top level and on each period.

Field meanings. grossProfit / grossProfitPence is net sales minus all non-wage costs (materials and stock, rent, energy and other overheads) — that is, profit before wages, not true gross profit. marginPct is that figure as a percentage of net sales (margin before wages). The field names are unchanged so existing integrations keep working; CIP shows these two figures as “Profit before wages” and “Margin before wages”.

Category-based figures (optional fields). Once a business has categorised its lines, each period may also carry revenue, costOfSales, grossProfitTrue (revenue minus cost of sales: true gross profit), labourCost, overheads, operatingProfit and uncategorisedCosts — each also as a …Pence integer in minor units — plus the percentages gpPct, foodGpPct, drinkGpPct (the food and drink splits, filled only for businesses that categorise that way), primeCostPct and refundSharePct. Refunds are taken off revenue. A field is left out when it can't be worked out for that period (for example a percentage of zero revenue), so treat every one as optional.

Alongside them, costOfSalesKnown (true or false) says whether cost of sales is known for the period. It is false while some costs are still Uncategorised and none has been sorted into a cost-of-sales category. Then costOfSales, grossProfitTrue, gpPct, foodGpPct, drinkGpPct and primeCostPct are not meaningful (gross profit would simply equal revenue), so don't show them as facts. CIP itself hides them until the costs are sorted.

GET /api/v1/insights?period=2026-07 — that period's open insights, most severe first. Each carries its key, the stable name of the rule that raised it (null for an older insight), so you can match the same insight across months.

{ "period": "2026-07", "insights": [{ "message": "Labour is 34.2% of sales — above your 30% target.", "severity": "warn", "key": "labour_high" }] }

GET /api/v1/reports — every generated report's period, type and whether it has a public share link. The share link's actual token is never returned here — it's a bearer credential for an unauthenticated public URL, so it only ever leaves the app through the Reports page's own copy-link action, where a person deliberately copies it.

{ "reports": [{ "period": "2026-07", "type": "monthly", "hasShareLink": true, "createdAt": "2026-08-01T09:00:00.000Z" }] }

Changelog

  • 18 October 2026 — Team figures (hours, overtime, utilisation and the rest) through an API key are by site and month only. Breaking one down or filtering it by area or job role returns a 400 that says so. In return a site's monthly figure now shows unless that site had only one or two people in it that month, so nobody can be singled out.
  • 18 October 2026 — GET /api/v1/metrics accepts optional grain (month, week or day) and by (venue, class or category) to break the figures down. Leave both out and you get exactly the response you get today. Breaking down by site needs the multi-site add-on, and any breakdown needs Explore (Silver and above).
  • 18 October 2026 — New on the Gold plan: POST /api/v1/query asks any metric question. Send one JSON question and get the answer back with your currency. It needs a read-scope key and allows 60 questions a minute per key. An unreadable or not-allowed question gets a 400 with a plain-words reason; a body over 64 KB gets a 413; a question that takes too long to answer gets a 503.
  • 18 October 2026 — Every /api/v1 response now carries Cache-Control: no-store and X-Content-Type-Options: nosniff, so your figures are never kept in a shared cache. Response bodies are unchanged. A signed-in session can call POST /api/v1/query only from CIP's own pages (a cross-site request gets a 403); API keys are not affected.
  • 18 October 2026 — API keys always see whole months. Asking for grain=week or grain=day with a key returns a 400 that says so; only a signed-in owner gets weekly or daily detail. A key can never break figures down by person. Additive only.
  • 13 October 2026 — GET /api/v1/metrics gains costOfSalesKnown beside the category-based fields (see the read API above). Additive only.
  • 13 October 2026 — GET /api/v1/metrics gains currency, the ISO 4217 code of the business's currency that every amount is in. Additive only.
  • 12 October 2026 — GET /api/v1/metrics gains optional category-based fields (revenue, grossProfitTrue, gpPct, primeCostPct and the rest, listed under the read API above). Additive only: every existing field keeps its name and meaning.
  • 12 October 2026 — POST /api/ingest JSON rows accept optional counterparty (or supplier) and description.

Error codes

  • 400 — malformed request (bad JSON, invalid field, unknown schema version).
  • 401 — missing/invalid/revoked API key, or no session.
  • 403 — key/session valid but not entitled (wrong scope, wrong plan, account not writable/not fully set up).
  • 404 — (read API only) no data for the requested period.
  • 409 — a request with this Idempotency-Key is already being processed.
  • 413 — request body over 5 MB.
  • 422 — this Idempotency-Key was already used with a different request body.
  • 429 — rate limit exceeded; see Retry-After.