For developers

Your sales, in your own code.

A read-only HTTP API over everything a venue has sold: orders, the items on them, what each one cost in tax, how the money arrived, and what went back. Point your own reporting at it, or somebody else's.

Every amount is an integer number of pence. Every timestamp is ISO 8601 with a zone. Nothing here can change a venue's trade — the whole API is GET, apart from the one call that mints a token.

Getting a key

You generate an Ed25519 keypair and send us the public half. We never hold anything that can impersonate you — the private key stays on your machine, and losing our copy of the public one costs you nothing.

ssh-keygen -t ed25519 -f helchpos-api -N ""   # or the two lines below in Node

const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
const raw = publicKey.export({ format: 'der', type: 'spki' }).subarray(-32)
console.log(raw.toString('base64'))           # this is what you send us

Email that base64 string to hello@helch.uk with the name of the food hall or the venue it should read. You get back a connection ID — a small number. A key is scoped to one food hall, or to a single kitchen inside one; it can never see another.

Signing in

Sign the current time with your private key and exchange the signature for a token. The token lasts just under a day, so a nightly job can mint a fresh one every run and never store one.

const timestamp = new Date().toISOString()
const signature = crypto.sign(null, Buffer.from(timestamp), privateKey).toString('base64')

const r = await fetch('https://helchpos.com/api/v1/connections/7/access-token', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ timestamp, signature }),
})
const { just: token } = await r.json()

Then put it on every other request:

curl -H "Authorization: Bearer $TOKEN" \
  "https://helchpos.com/api/v1/limits"

The timestamp has to be within five minutes of now, so a signature copied off the wire yesterday is not a key. Revoking a connection kills every token it has already issued, immediately — the connection is checked on every request, not baked into the token.

Conventions

RuleWhat it means
MoneyAlways an integer number of pence. 1250 is £12.50. There are no floats anywhere in this API.
TimeISO 8601 with a zone: 2026-08-12T19:00:00.000Z.
IdsStrings. An order's id is its permanent uid, not a row number — row numbers are guessable and count upwards, which tells anybody holding one how many sales a venue has made.
Single valuesWrapped: { "just": "…" }. Always an object, so adding a field later never breaks a parser.
Relationshipsnull means not asked for. An empty array means asked for, and there are none. They are different answers and this API never confuses them.
Pagingoffset and num, maximum num of 100. Fewer rows than you asked for means the end of the collection.
Windowsfrom and to are required on anything that could return a year of trade. A missing window is a 400, not "since the beginning".
UnknownsA tax nobody worked out comes back as null, never 0. Zero is a decision somebody made about a cold sandwich; null means the record does not know.

Endpoints

Base URL https://helchpos.com/api/v1. A business is a food hall.

MethodPath
POST /connections/{id}/access-token Signature in, token out. The only call that is not a GET.
GET /limits What this key may read: its business, its venues, the page maximum. Needs no window — call it first.
GET /businesses/{id} The food hall.
GET /businesses/{id}/sites Its kitchens.
GET /businesses/{id}/products Stock items. offset, num.
GET /businesses/{id}/orders Every order in the window, whatever state it is in.
GET /businesses/{id}/orders/completed Only the ones that can no longer change.
GET /businesses/{id}/payments Every payment in the window, each naming its order.
GET /businesses/{id}/refunds Every refund in the window, with its line breakdown.
GET /sites/{id} One kitchen.
GET /sites/{id}/orders/completed Completed orders for one kitchen.
GET /orders/{id} One order by its uid, with items, payments and refunds expanded.

Everything with a window takes from, to, offset and num.

The order

{
  "id": "df1297aba4d78558b03d957dcc8ca4fe",
  "reference": "H-193",
  "status": "refunded",
  "channel": "pos",
  "site": { "id": "2" },
  "check": null,
  "currency": "GBP",
  "created_at": "2026-08-12T20:41:38.000Z",
  "amount": 3600,
  "tax_amount": 600,
  "service_charge_amount": 0,
  "surcharge_amount": 0,
  "tip_amount": 0,
  "staff_discount_amount": 500,
  "staff_discount_name": "Manager discount",
  "bundle_discount_amount": 0,
  "loyalty_discount_amount": 0,
  "items": [
    {
      "id": "281",
      "sku": "BACON-CHEESE",
      "name": "BACON CHEESE",
      "category": "Burgers",
      "quantity": 2,
      "unit_amount": 1400,
      "amount": 2800,
      "tax_amount": 410,
      "tax_rate": 20,
      "deal_amount": 0,
      "staff_discount_amount": 341,
      "bundle_discount_amount": 0,
      "loyalty_discount_amount": 0,
      "refunded": true,
      "modifiers": [{ "name": "Extra bacon", "quantity": 1, "amount": 150 }]
    }
  ],
  "payments": [
    {
      "id": "255cd86e182584aaaa414cba93e673f6",
      "method": "card",
      "amount": 3600,
      "tip_amount": 0,
      "acquirer_fee_amount": 0,
      "platform_fee_amount": 0,
      "reference": "",
      "terminal": "",
      "taken_by": "Jamal",
      "created_at": "2026-08-12T20:41:38.000Z"
    }
  ],
  "refunds": [
    {
      "id": "6cc4109b1c5b12032ec5d00393ca3951",
      "amount": 2459,
      "tax_amount": 410,
      "reason": "Wrong order",
      "note": "",
      "method": "card",
      "restocked": false,
      "refunded_by": "Jamal",
      "created_at": "2026-08-12T20:41:38.000Z",
      "items": [{ "item_id": "281", "quantity": 2, "amount": 2459, "tax_amount": 410 }]
    }
  ]
}

Things worth knowing about that shape

FieldType
amountpenceOn an order, what the guest paid. On an item, what that line came to after the deal on it.
tax_amountpence | nullThe VAT inside that amount, recorded at the moment of sale. null on a sale taken before the venue had worked its rate out — and on the order too, because a total that silently omits one line is worse than an honest gap.
categorystring | nullThe category as it read on the day. Renaming a category today does not move last quarter’s sales.
staff_discount_amountpenceOn an item: this line’s share of the order-level discount, apportioned by what the line was worth. The shares add back to the order exactly.
payments[]arrayOne row per payment. A bill split across cash and card is two rows, each with its own time and whoever took it.
refunds[]arrayOne row per refund. Two partial refunds on different days are two records with their own reasons, amounts and restock decisions.
checkobject | nullSet when the order was part of one basket paid across several kitchens. Each kitchen still keeps its own order; the check is what the guest experienced.

Errors

CodeWhen
400Something in the request was wrong. The body names each parameter and what was wrong with it, so you can fix them all at once.
401No token, a forged token, an expired one, or a connection that has been switched off.
404The thing does not exist, or it is not yours. Deliberately the same answer: telling you an id exists is itself an answer.
{ "from": "required", "to": "required" }
{ "num": "too-large" }
{ "to": "before-from" }
{ "timestamp": "out-of-range" }

Want a key?

Send the public half and say which hall or kitchen it should read. There is no charge for the API and no rate card — if you manage to make it a problem, we will talk about it then.

Ask for a key