# Floor Lookup API reference

For developers integrating a POS, ERP or warehouse system — and written to be
equally readable by an AI agent. If you were handed a URL and an API key and
asked to manage a catalog or stock levels, everything you need is on this page;
the machine-readable copy lives at `/docs/api.md`.

---

## 1. The API in one page

Base URL: the hub address you were given (for the hosted service,
`https://floorlookup.app`). All endpoints are HTTPS + JSON.

**Authentication** — one header on every call:

```
Authorization: Bearer SAK1.<tenant-uuid>.<secret>
```

The key is created on the Connectors page of the dashboard and shown once.
It authorises exactly one tenant; the tenant is inside the key, so there is no
tenant parameter anywhere.

**Writing (managing products and stock):**

```
POST /v1/push/catalog     body: {"rows":[{"sku":"A-1","name":"Boot","price":99.5,"barcodes":["5060001"]}]}
POST /v1/push/stock       body: {"rows":[{"sku":"A-1","siteId":"WH-1","onHand":4,"asOf":"2026-07-30T09:00:00Z"}]}
```

Rules that matter before your first call:

- Default mode is **incremental**: only the rows you send change, nothing else
  is touched. Delete a product by sending `{"sku":"A-1","deleted":true}`.
- Every stock row **must** carry `asOf` — the timestamp when that count was
  true in the source system (§7). Rows without it are rejected.
- `siteId` must be one of the vendor codes registered in the connector's
  `siteMap` (§6). An unknown site is a hard error, never a guess.
- Validation is all-or-nothing per batch: one bad row rejects the whole batch
  and the response lists **every** problem with row indexes. Nothing partial is
  ever written.
- Batches are idempotent. Re-sending the same batch reports `"changed": 0` and
  disturbs nothing — this is also the recommended way to verify your work.
- Body cap 64 MB; rate limit 60 batches/min per key (HTTP 429 + `Retry-After`).

**Reading (sales handoffs your shop-floor staff create):**

```
GET /v1/sales?since=<cursor>     the feed, oldest first, returns the next cursor
GET /v1/sales/{id}               one handoff by id, or ref:<clientRef>
GET /v1/sales?code=K7Q2X         look one up by the code read out at the till
```

**There is no read-back API for catalog or stock.** Pushing is one-way by
design — your system is the source of truth and the hub is a read model of it.
An agent managing the catalog should keep its own record of what it sent and
use the `changed` counts and idempotent re-sends to verify state.

**Error envelope**, everywhere:

```json
{"error": "machine_code", "message": "human explanation"}
```

`401` never distinguishes a wrong key from a missing one. `403 tenant_suspended`
means the account is paused (billing or operator action) — stop and tell a
human; retrying will not help.

Everything below is the detail behind this summary.

---

## 2. How integration works

**Floor Lookup never writes to your POS or ERP.** Ingestion is one-way: we read your catalog and
stock, and the sync service holds read-only credentials to your side by design.

There is exactly one thing that travels the other way, and you opt into it: **basket handoffs**
(§10). It carries only what your associates built on the shop floor, only to an address you
register, and only when you switch it on. It never touches your catalog or your stock.

A *connector* is a recipe. It is a small JSON document — we call it a **profile** — that tells us
what your data looks like and how it reaches us.

There are two kinds, and you choose one when you add a connector in the dashboard:

- **Pull** — we call your API on a schedule, fetch everything it will list, and work out what
  changed by comparing it to what we already hold. Best when your system has an API we can reach
  over the internet.
- **Push** — your system posts batches to a URL we give you, whenever you like. Best when your
  system sits behind a firewall, has no public API, or already emits changes as they happen.
  See *Sending data to us instead (push)* below.

Whichever you choose, the profile answers the same questions: what your fields are called, so we
can translate them into the handful of fields the Floor Lookup app understands; and which of your
warehouse or store codes correspond to which of your Floor Lookup sites. Everything after that
translation is identical — the same rules, the same freshness stamps, the same treatment of
removals.

Ingestion carries nothing else: no orders, no adjustments, and no writes to your system. The one
thing that travels the other way is a basket handoff (§10), and it is off unless you turn it on.

Two further strategies are planned and not yet built: polled delta (we ask only for what changed)
and file drop (you leave a file on SFTP or S3). They appear in the dashboard marked *Planned*, and
a profile naming one is rejected with a message saying so rather than pretending it is a typo.

---

## 3. Authentication and API keys

Pick **Push — your system sends to us** when you add a connector in the
dashboard. It shows the two URLs and generates an API key (`SAK1.…`). The key
is displayed once; we store only a hash, so a lost key means creating another
and revoking the old one — revocation is immediate.

Every failure to authenticate returns the identical `401` body: a missing
header, a malformed key, an unknown key and a revoked key are deliberately
indistinguishable from outside.

A disabled connector is distinguishable, on purpose: `403 connector_disabled`
means your key is fine and an admin turned the connector off — a thirty-second
fix on the Connectors page rather than an afternoon of key debugging.

---

## 4. Pushing catalog and stock

The field names, the `siteMap` rule and the `asOf` rule are exactly the same
as everywhere else in this reference; this section is the write path itself.

### The two endpoints

```
POST https://<your-hub>/v1/push/catalog
POST https://<your-hub>/v1/push/stock
Authorization: Bearer SAK1.…
content-type: application/json
```

### The body

```json
{
  "mode": "incremental",
  "declaredRowCount": 2,
  "rows": [
    { "sku": "V-100", "name": "Vendor Boot", "category": "Footwear",
      "price": 120.00, "barcodes": ["5060001"], "updatedAt": "2026-07-28T08:00:00Z" },
    { "sku": "V-200", "deleted": true }
  ]
}
```

Stock rows look like this, and `asOf` is required on every one:

```json
{ "sku": "V-100", "siteId": "WH-MAIN", "onHand": 4, "asOf": "2026-07-28T09:30:00Z" }
```

| Field | Required | Meaning |
|---|---|---|
| `rows` | yes | The rows themselves. An empty array is valid and counts as a heartbeat. |
| `mode` | no | `incremental` (default) or `snapshot`. See below — the default never deletes anything you did not name. |
| `declaredRowCount` | in snapshot mode | How many rows you believe you are sending. If it disagrees with what arrives, we reject the batch rather than act on a truncated body. |
| `confirmLargeDelete` | no | Only meaningful in snapshot mode. See below. |

Use your own field names if renaming them is hard: the connector's advanced settings accept the same
dot-path mapping described in §8.

### Incremental and snapshot

**Incremental is the default and cannot delete anything you did not name.** Send the rows that
changed; everything else is left alone. To remove a product, send it with `"deleted": true`. For
stock, `"deleted": true` records zero at your `asOf`, because "as of this time, this site holds
none" is a fact with a time attached.

**Snapshot mode** means "this batch is my entire catalog; delete anything missing". It is powerful
and it is how a truncated upload becomes a mass deletion, so four things must all be true before a
single row is removed:

1. You send `"mode": "snapshot"`.
2. An admin has ticked **Allow full-snapshot batches** on the connector.
3. Your `declaredRowCount` matches the rows that actually arrived.
4. The deletion is under the connector's limit (20% of your live catalog by default). Over that, we
   refuse with a `409` telling you exactly how many rows it would have removed. If the mass
   discontinuation is genuine, resend with `"confirmLargeDelete": true`.

If any of the four is not met, **nothing is written at all** — not even the rows that were fine.

### What you get back

```json
{ "batchId": "0f3c…", "receivedAt": "2026-07-28T09:31:02Z",
  "mode": "incremental", "accepted": 1204, "changed": 37, "deleted": 2 }
```

`changed` counts rows that were actually different from what we held. Sending us the same batch
twice reports `changed: 0` the second time and does not disturb a single device — which is also the
easiest way to check your integration is behaving.

### When something is wrong

We validate every row before writing any of them, so one bad row means the batch is rejected and you
get the complete list of problems in one response rather than discovering them one deploy at a time:

```json
{
  "error": "row_validation_failed",
  "message": "2 of 1204 rows were rejected. Nothing was written — fix these and resend the batch.",
  "rejectedCount": 2,
  "rows": [
    { "index": 17, "sku": "V-100",
      "message": "vendor site \"WH-MYSTERY\" is not in siteMap" },
    { "index": 883, "sku": "V-900",
      "message": "stock row V-900/WH-MAIN carries no source asOf" }
  ]
}
```

| Status | Meaning |
|---|---|
| `200` | Applied. |
| `400` | The body is malformed, or `declaredRowCount` disagrees with what arrived. |
| `401` | The key is missing, malformed, unknown or revoked. We do not say which. |
| `403` | The connector is disabled, the endpoint is not enabled, or you asked for a snapshot without permission. |
| `409` | The snapshot would delete more than the limit allows. |
| `413` | The body is over 64 MB. Send smaller batches. |
| `422` | One or more rows are invalid. Nothing was written. |
| `429` | Too many batches. Wait for the interval in `Retry-After`. |

### A complete worked example

Say your POS can export this — the exact shape does not matter, only that you can read it:

```json
{
  "products": [
    { "ItemCode": "TR-1180", "Description": "Ridgeline 40L Pack",
      "LongText": "Lightweight 40 litre hiking pack with a wire frame.",
      "Dept": "Packs", "Price": "189.00", "EAN": "5060112730313",
      "LastChanged": "2026-07-28T07:15:00Z" }
  ]
}
```

You have two ways to send it.

**Option A — translate on your side (recommended).** Post our field names. This is the least
work for us to support and the easiest for you to debug, because what you send is what we store:

```bash
curl -X POST https://<your-hub>/v1/push/catalog \
  -H "Authorization: Bearer SAK1.…" \
  -H "content-type: application/json" \
  -d '{
    "rows": [
      { "sku": "TR-1180",
        "name": "Ridgeline 40L Pack",
        "description": "Lightweight 40 litre hiking pack with a wire frame.",
        "category": "Packs",
        "price": 189.00,
        "currency": "GBP",
        "barcodes": ["5060112730313"],
        "updatedAt": "2026-07-28T07:15:00Z" }
    ]
  }'
```

**Option B — send your own field names.** If renaming is awkward, put a `map` in the connector's
advanced settings and post your export unchanged:

```json
{ "sku":       {"path": "ItemCode"},
  "name":      {"path": "Description"},
  "description": {"path": "LongText"},
  "category":  {"path": "Dept"},
  "price":     {"path": "Price", "transform": "number"},
  "barcodes":  {"path": "EAN"},
  "updatedAt": {"path": "LastChanged", "transform": "millis"} }
```

Note `"transform": "number"` on Price: the export sends `"189.00"` as a string, and that turns it
into a number. Dot-paths work too, so a price nested at `Pricing.Retail` is written
`{"path": "Pricing.Retail"}`.

Both options produce exactly the same stored product. Pick whichever is less work for you.

### The stock call, which is the one that matters daily

Products change rarely; counts change constantly. This is the call your integration will make most:

```bash
curl -X POST https://<your-hub>/v1/push/stock \
  -H "Authorization: Bearer SAK1.…" \
  -H "content-type: application/json" \
  -d '{
    "rows": [
      { "sku": "TR-1180", "siteId": "WH-MAIN", "onHand": 6,  "asOf": "2026-07-28T09:30:00Z" },
      { "sku": "TR-1180", "siteId": "WH-SOUTH","onHand": 0,  "asOf": "2026-07-28T09:30:00Z" },
      { "sku": "CP-4001", "siteId": "WH-MAIN", "onHand": 12, "asOf": "2026-07-28T09:30:00Z",
        "backroom": 8 }
    ]
  }'
```

`siteId` is **your** code — `WH-MAIN` here — and the connector's site map turns it into one of your
Floor Lookup sites. `asOf` is when that count was true in your system, not when you sent it; see
§7 for why we insist.

### Removing a product

```json
{ "rows": [ { "sku": "TR-1180", "deleted": true } ] }
```

Nothing else is needed — no name, no price. For stock, `"deleted": true` records zero at that site:

```json
{ "rows": [ { "sku": "TR-1180", "siteId": "WH-SOUTH", "deleted": true,
              "asOf": "2026-07-28T09:30:00Z" } ] }
```

### Replacing the whole catalog

Only if an admin has enabled it on the connector, and only when you are genuinely sending
everything:

```json
{ "mode": "snapshot",
  "declaredRowCount": 4812,
  "rows": [ "… all 4812 products …" ] }
```

Anything you do not include is marked discontinued. `declaredRowCount` must match, which is how a
body cut short in transit is caught before it deletes anything.

### The replies you should handle

Success:

```json
{ "batchId": "0f3c9a2e-…", "receivedAt": "2026-07-28T09:31:02Z",
  "mode": "incremental", "accepted": 3, "changed": 1, "deleted": 0 }
```

`changed: 1` means only one of the three rows actually differed from what we held. Sending the same
batch again returns `changed: 0` — useful as a health check, and proof your integration is not
churning devices.

A rejected batch, with every problem listed at once:

```json
{ "error": "row_validation_failed",
  "message": "1 of 3 rows were rejected. Nothing was written — fix these and resend the batch.",
  "rejectedCount": 1,
  "rows": [
    { "index": 1, "sku": "CP-4001",
      "message": "vendor site \"WH-NORTH\" is not in siteMap" }
  ] }
```

`index` is the position in the array you sent. Treat any non-`200` as "nothing was stored" and
resend the whole batch after fixing — we never apply a batch partially.

### Heartbeat

Tell us how often you will send, and then send at least that often **even when nothing has
changed** — an empty `rows` array is a valid heartbeat.

This matters more than it looks. If you only contact us when something changes, we cannot tell a
quiet Sunday from an integration that died on Friday, and neither can you. With a promised cadence,
the Connectors page turns silence into a visible warning instead of a shrug.

---

## 5. The canonical fields

These are the only field names the `map` object understands. Anything else in your rows is ignored.

### Catalog

| Canonical field | Required | Expected type after transform | What it drives |
|---|---|---|---|
| `sku` | **yes** | non-empty string | The product's identity everywhere in the app, and the join to stock rows. A row without it fails the sync. |
| `description` | no | Longer product text, shown on the product page in the dashboard. Omit it entirely if you have none — an empty string is indistinguishable from a real one. |
| `name` | **yes** | non-empty string | The product title associates see in search and on the product screen. A row without it fails the sync. |
| `category` | no | string | Grouping and filtering. Missing or empty becomes `Uncategorised`. |
| `price` | no | number | The retail price shown on the product screen. Missing leaves it at 0, so map it. |
| `currency` | no | string | Currency shown next to the price. Defaults to `USD`; set it with `default` if you sell in anything else. |
| `marginPct` | no | number | Margin percentage, visible only to roles permitted to see it. |
| `barcodes` | no | array of strings, or one string | **Drives the barcode scanner.** Without it, scanning a product finds nothing. A single string is accepted and stored as one barcode. |
| `imageUrl` | no | string (https URL) | Product photo, shown on the handset's product screen and in the dashboard. Point it at your own image host — we never copy or store the image itself. |
| `updatedAt` | no | whole number, milliseconds | When your system last changed the product. Use `transform: "millis"`. |

`price` and `marginPct` are only stored if the mapped value is a number, so use
`transform: "number"` when your API sends prices as strings. `updatedAt` is only stored if the value
is a whole number, which is what `millis` produces.

**Removals are soft.** Nothing is ever hard-deleted. A removed product is marked inactive, its
version advances so devices learn about it, and a product that reappears later comes back
automatically.

How we *learn* about a removal depends on the connector type:

- **Pull:** the catalog endpoint is treated as a full snapshot. Any product we already hold that is
  absent from the feed is marked deleted. This does mean that if your endpoint silently returns a
  partial list, everything missing will be marked deleted — so make sure the catalog path lists your
  whole catalog, and that pagination is configured if it is paged.
- **Push:** absence means nothing, because a batch is normally only the rows that changed. You state
  a removal explicitly by sending the product with `"deleted": true`. A push connector *can* opt into
  snapshot behaviour, but it is off by default and guarded — see §4.

### Stock

| Canonical field | Required | Expected type after transform | What it drives |
|---|---|---|---|
| `sku` | **yes** | non-empty string | Ties the count to a catalog product. A row without it fails the sync. |
| `siteId` | **yes** | non-empty string | Your location code. Must appear in `siteMap` (§6) or the sync fails. |
| `onHand` | no | whole number or number | The count associates see. Missing leaves it at 0, so map it. |
| `backroom` | no | whole number | Optional backroom-versus-floor split. Only stored when the value is a whole number, so use `transform: "int"`. |
| `asOf` | **yes** | whole number, milliseconds | **The freshness stamp** — "as of 6 minutes ago" on the associate's screen. A row without it fails the sync. See §7. |

Stock rows are updated in place and are not soft-deleted; a location that stops reporting keeps its
last known count and its last known `asOf`, which will visibly age in the app.

---

## 6. `siteMap`, and why an unmapped site is a hard error

`siteMap` translates your location codes into Floor Lookup site ids:

```json
"siteMap": { "WH-MAIN": "main", "WH-DEPOT": "depot" }
```

Every stock row's mapped `siteId` is looked up in this table. If it is not there, the row is
rejected and the whole sync run fails with `vendor site "WH-MYSTERY" is not in siteMap`.

We do this on purpose, and we would rather fail your sync than guess. The worst thing this product
can do is show an associate a confident number for the wrong store. Someone walks to a shelf, or
promises a customer a pickup, on the strength of a count that belongs to a different building. A
failed sync is visible, annoying and fixed in a minute. A silently misattributed count is invisible
and erodes every number in the app.

So there is no fuzzy matching, no fallback to a default site, and no pass-through of unrecognised
codes. When you open a new location, add it on the Sites page and add the mapping here.

---

## 7. Why `asOf` is mandatory on stock rows

The app promises associates an honest freshness age next to every count. That promise is only worth
something if the timestamp is the moment **your system** believed the count, not the moment we wrote
it down.

If we stamped our own clock, every count would look seconds old, including one that came from a
nightly file exported eleven hours ago. The associate would trust it exactly as much as a live one.
That is the failure this rule exists to prevent, so a stock row with no source timestamp is
rejected: `stock row V-100/WH-MAIN carries no source asOf`.

Find the field in your feed that carries the snapshot or last-counted time and map it. If your API
genuinely has no such field, tell us what its export cadence is; the honest answer is usually a
constant offset expressed in your export, not a made-up now.

One consequence worth knowing: because `asOf` is part of what we compare when deciding whether a row
changed, a feed that advances the timestamp on every row every run will mark every row as changed
every run. Ideally, your timestamp moves when the count moves.

---

## 8. Pull: we call your API instead

Everything in §4 describes your system posting to us. A pull connector is the
same data flowing the other way: our sync worker calls your API on a schedule
(default every 5 minutes) and works out what changed. You write no code — you
fill in a JSON *profile* in the dashboard describing your endpoints and field
names.

### What you need

If you are pushing to us rather than being polled, you need only the last two rows of this table
plus the ability to make outbound HTTPS calls — skip ahead to §4.


| You need | Notes |
|---|---|
| An HTTP endpoint that lists products | Returns JSON. Must include a stable product code and a name. |
| An HTTP endpoint that lists stock levels | Returns JSON. Must include a product code, a location code, a quantity, and **a timestamp saying when that count was true**. |
| Credentials for those endpoints | A bearer token, or a username and password for HTTP Basic. Read-only credentials, please. |
| Your location codes | The exact strings your system uses for warehouses and stores, e.g. `WH-MAIN`. |
| Your Floor Lookup site ids | Created on the Sites page in the dashboard before you configure the connector. |

Both endpoints must be reachable from our sync worker over HTTPS. Each request is given 30 seconds
and a response body limit of 64 MB.

You can configure only one of the two endpoints if you want to start with catalog alone, but a
profile with neither is rejected.

---


### The profile, field by field

### Top level

| Field | Required | Type | Meaning |
|---|---|---|---|
| `source` | yes | object | Where to fetch from, and how to authenticate. |
| `catalog` | one of the two | object | The products endpoint and its field mapping. |
| `stock` | one of the two | object | The stock-levels endpoint and its field mapping. |
| `strategy` | no | string | `snapshot_diff` (default, pull) or `push`. `polled_delta` and `file` are planned and rejected for now. The dashboard sets this for you. |
| `siteMap` | needed if you use `stock` | object | Your location code → your Floor Lookup site id. See §6. |

At least one of `catalog` or `stock` must be present.

### `source`

| Field | Required | Meaning |
|---|---|---|
| `baseUrl` | yes | Scheme and host, plus any common prefix, e.g. `https://erp.example.com`. Endpoint paths are appended to it verbatim. |
| `auth.type` | yes | `none`, `bearer` or `basic`. |
| `auth.token` | for `bearer` | Sent as `Authorization: Bearer <token>`. |
| `auth.user`, `auth.secret` | for `basic` | Sent as HTTP Basic authentication. |

Anything other than `bearer` or `basic` results in no authentication header being sent, so use
`none` deliberately rather than by accident.

Credentials are stored in the connector profile in our control database. Use a read-only service
account, and rotate it through the dashboard if it is ever exposed.

Once saved, a credential is never sent back to your browser: the profile editor shows `••••••` in
its place. Leave that `••••••` alone to keep the stored value, type a new one to replace it, or
empty the field to remove it.

### `catalog` and `stock` (endpoint configuration)

| Field | Required | Meaning |
|---|---|---|
| `path` | yes | Appended to `baseUrl`, e.g. `/erp/products`. |
| `itemsField` | no | The name of the JSON field holding the array of rows. Leave it out if the response body *is* the array. |
| `pagination` | no | `none` (the default) or `page`. |
| `pageParam` | no | Query parameter name for page numbers. Defaults to `page`. |
| `map` | yes | Canonical field name → field mapping. See §4 and §5. |

**`itemsField` in practice.** If your endpoint returns:

```json
[ { "ItemCode": "V-100" } ]
```

leave `itemsField` out. If it returns:

```json
{ "data": [ { "ItemCode": "V-100" } ], "total": 812 }
```

set `"itemsField": "data"`. It names one top-level field only; it is not a dot-path.

**`pagination` in practice.** With `"pagination": "page"`, we request `?page=1`, `?page=2` and so on,
using `pageParam` if you set a different name, and we stop when a page comes back with zero rows.
All pages are concatenated before mapping. There is a hard stop at 10,000 pages so a misbehaving
endpoint cannot wedge the worker. Cursor and `Link`-header pagination are not supported in this
version; if your API only offers those, ask us about the code escape hatch.

### `map` entries (one per canonical field)

| Field | Required | Meaning |
|---|---|---|
| `path` | usually | Dot-path into one of your rows, e.g. `Pricing.Retail`. |
| `transform` | no | One of the values in §4. Omitted means the value is taken as-is. |
| `default` | no | Literal value used when `path` is missing from a row — or the whole value, if you omit `path`. |

If `path` is absent from a row and there is no `default`, the field is treated as missing. What
happens then depends on the field: see §5.

---


### Field mapping

### Dot-paths

A path walks down nested JSON objects. Given this row:

```json
{
  "ItemCode": "V-100",
  "Description": "Vendor Boot",
  "Pricing": { "Retail": 120.0, "Cost": 71.5 },
  "EANs": ["111", "112"]
}
```

- `ItemCode` yields `"V-100"`
- `Pricing.Retail` yields `120.0`
- `EANs` yields the whole array

Paths walk objects only. You cannot index into an array (`EANs.0` will not work), and you cannot
combine two of your fields into one of ours. If you need that, the value has to be shaped by your
API or by the code escape hatch.

### Transforms

| `transform` | What it does |
|---|---|
| omitted or `none` | Passes the value through unchanged. |
| `string` | Formats any value as text. |
| `number` | Produces a decimal number. Accepts a JSON number, or a string that parses as a number (surrounding whitespace is trimmed). Fails on anything else. |
| `int` | As `number`, then truncates toward zero to a whole number. |
| `millis` | Produces a Unix timestamp in **milliseconds**. See the note below. |
| `seconds` | As `millis`, then divides by 1000 to give Unix **seconds**. |
| `bool` | Passes a JSON boolean through. A string counts as true when it is exactly `true`, `1`, `yes` or `Y`. A number is true when it is non-zero. Fails on other types. |

Any other value is rejected with `unknown transform`.

**How `millis` reads your timestamps.** Floor Lookup stores time as Unix milliseconds, so this
transform has to accept the three shapes vendors actually send:

- **A string** is parsed as RFC 3339 (`2026-07-28T09:15:00Z`). Surrounding whitespace is trimmed.
  Any other string format fails with `not a timestamp`.
- **A number below 1e11** is treated as Unix *seconds* and multiplied by 1000.
- **A number at or above 1e11** is treated as Unix *milliseconds* and kept as-is.

The 1e11 threshold is a deliberate, documented heuristic: real feeds do not carry second-precision
timestamps beyond the year 5138, so the magnitude is unambiguous in practice. If your API emits
timestamps in any other format — `20260728091500`, or a local time with no zone — convert it on your
side, because we will not guess.

---


### A complete worked example

Your ERP speaks its own dialect. Here is a realistic one.

`GET https://erp.example.com/erp/products`

```json
{
  "data": [
    { "ItemCode": "V-100", "Description": "Vendor Boot", "Dept": "Footwear",
      "Pricing": { "Retail": 120.0 }, "EANs": ["111"],
      "Modified": "2026-07-28T08:00:00Z" },
    { "ItemCode": "V-200", "Description": "Vendor Jacket", "Dept": "Shells",
      "Pricing": { "Retail": 240.0 }, "EANs": ["222", "223"],
      "Modified": "2026-07-28T08:00:00Z" }
  ]
}
```

`GET https://erp.example.com/erp/stock`

```json
{
  "data": [
    { "ItemCode": "V-100", "Warehouse": "WH-MAIN",  "QtyOnHand": 4, "Snapshot": "2026-07-28T09:30:00Z" },
    { "ItemCode": "V-100", "Warehouse": "WH-DEPOT", "QtyOnHand": 9, "Snapshot": "2026-07-28T09:30:00Z" },
    { "ItemCode": "V-200", "Warehouse": "WH-MAIN",  "QtyOnHand": 0, "Snapshot": "2026-07-28T09:30:00Z" }
  ]
}
```

The profile that maps it:

```json
{
  "source": {
    "baseUrl": "https://erp.example.com",
    "auth": { "type": "bearer", "token": "REPLACE_WITH_READ_ONLY_TOKEN" }
  },
  "strategy": "snapshot_diff",
  "siteMap": { "WH-MAIN": "main", "WH-DEPOT": "depot" },
  "catalog": {
    "path": "/erp/products",
    "itemsField": "data",
    "map": {
      "sku":       { "path": "ItemCode" },
      "name":      { "path": "Description" },
      "category":  { "path": "Dept" },
      "price":     { "path": "Pricing.Retail", "transform": "number" },
      "currency":  { "default": "USD" },
      "barcodes":  { "path": "EANs" },
      "updatedAt": { "path": "Modified", "transform": "millis" }
    }
  },
  "stock": {
    "path": "/erp/stock",
    "itemsField": "data",
    "map": {
      "sku":    { "path": "ItemCode" },
      "siteId": { "path": "Warehouse" },
      "onHand": { "path": "QtyOnHand", "transform": "int" },
      "asOf":   { "path": "Snapshot", "transform": "millis" }
    }
  }
}
```

Notice `currency`, which has a `default` and no `path`: your feed does not carry a currency, so the
literal is used for every row.

What an associate then sees, after searching or scanning `111`:

- **Vendor Boot**, category Footwear, $120.00
- Main Street: **4 on hand**, as of 09:30 — displayed as a freshness age
- Depot: **9 on hand**, as of 09:30
- Scanning either `222` or `223` opens Vendor Jacket, which shows 0 at Main Street

If your ERP later discontinues `V-200` and stops listing it, the next run marks it deleted and it
disappears from the app, without anyone touching the profile.

---

## 9. Choosing push, pull or file upload

| | Pull | Push | File upload |
|---|---|---|---|
| Who initiates | We call you on a schedule | You call us whenever you like | You upload a CSV in the dashboard |
| Needs a reachable API | Yes, from the internet | No — outbound HTTPS only | No — no API at all |
| Freshness | As fast as the schedule (5 min default) | As fast as you send | As fresh as your last upload |
| Removals | Absence from the feed | Explicit `"deleted": true`, or opt-in snapshot mode | Absence from the file (guarded: over 20% needs an explicit confirmation) |
| Effort on your side | Configuration only, if the API exists | A small amount of code that posts JSON | Export a spreadsheet |
| Good when | Your POS has a REST API we can reach | Your system is on-premise, or already emits events | You are trying the product, or your POS only exports files |

If both API options are possible, pull is less code for you. If your system is behind a firewall,
push works without asking your network team for anything. And if neither is set up yet, **file
upload gets your catalog live today**: create a File upload connector, export products (and
optionally stock counts) as CSV — Excel's "CSV UTF-8" is fine — and upload. We read your header
row, match columns like `Item Code`, `EAN` or `Retail Price` automatically, and ask about anything
we cannot match, once; the mapping is remembered for every upload after that. A products file is
treated as the complete catalog, so items missing from it are retired — which is why a file that
would remove more than 20% of live items is refused until you tick the confirmation box.

---

## 10. The sales feed: receiving basket handoffs

Your associates build baskets on the shop floor: they walk a customer round the shop, add items,
and read out a short code at the till. This section is about getting those baskets into your own
system, so the till operator does not re-key them.

**Read this part twice: no payment was taken.**

A handoff is a *draft order*. The app has no payment step and never will, so we cannot know
whether the customer bought anything, changed their mind at the counter, or walked out. Most point
of sale systems can open one as a parked or suspended sale, which is the right home for it. **Do
not post a handoff to your ledger, count it as revenue, or decrement stock from it.** If you do,
your sales figures will include baskets that never became sales.

Its `status` is `open` and stays there unless something changes it.

### The whole flow, in one place

Three steps. If you only read one part of this section, read this part.

**1. We notify you** (optional — skip to step 2 if you would rather poll):

```json
POST https://your-system.example.com/store-assist
X-StoreAssist-Event: handoff.created
X-StoreAssist-Timestamp: 1753660800
X-StoreAssist-Signature: v1=8f3c…

{
  "type": "handoff.created",
  "tenantId": "3b91…",
  "handoffId": "9f2c8a1e-4d77-4b0e-9c2a-1f5b6d8e0a33",
  "clientRef": "0f9c4b2a-77d1-4e93-8c6f-2ab5d90e1c47",
  "version": 84157,
  "occurredAt": "2026-07-29T09:14:03Z",
  "note": "This is a notification only. Fetch the handoff itself from GET /v1/sales?since=<cursor>, which is the source of truth. No payment was taken."
}
```

**2. You fetch the basket.** Either by the `handoffId` from step 1, or — at a till,
where all you have is what the associate said out loud — by the code:

```bash
curl -H "Authorization: Bearer SAK1.…" \
  "https://your-sales-endpoint/v1/sales?code=V22GL"
```

By id:

```bash
curl -H "Authorization: Bearer SAK1.…" \
  https://your-sales-endpoint/v1/sales/9f2c8a1e-4d77-4b0e-9c2a-1f5b6d8e0a33
```

**3. You get the whole thing:**

```json
{
  "id": "9f2c8a1e-4d77-4b0e-9c2a-1f5b6d8e0a33",
  "clientRef": "0f9c4b2a-77d1-4e93-8c6f-2ab5d90e1c47",
  "version": 84157,
  "status": "open",
  "handoffCode": "K72Q9",
  "siteId": "northgate",
  "currency": "GBP",
  "total": "214.00",
  "lineCount": 2,
  "capturedAt": "2026-07-29T09:14:02Z",
  "createdAt": "2026-07-29T09:14:03Z",
  "lines": [
    { "sku": "TR-1180", "name": "Ridgeline 40L Pack", "unitPrice": "189.00", "qty": 1 },
    { "sku": "TR-2040", "name": "Trail Sock",         "unitPrice": "12.50",  "qty": 2 }
  ]
}
```

That is a **draft order**. `status` is `open`, `total` is what the associate showed the customer,
and **no money changed hands**. Open it at a till; do not post it to a ledger.

If you would rather not run a listener at all, skip step 1 entirely and poll
`GET /v1/sales?since=<cursor>` on a timer. You lose nothing but latency — the feed returns the
same records, with the same `lines`, in the same shape.

---

### Turning it on

On the **Connectors** page, find your connector and switch **Basket handoffs** on. It is off by
default. That one switch controls everything below — the feed and the notification both.

### Reading them

```
GET https://<your sales endpoint>/v1/sales?since=<cursor>
Authorization: Bearer <the same API key your connector already uses>
```

The exact address is printed on your Connectors page. A reply looks like this:

```json
{
  "sales": [
    {
      "id": "9f2c…",
      "clientRef": "0f9c…",
      "version": 84157,
      "status": "open",
      "handoffCode": "K72Q9",
      "siteId": "northgate",
      "currency": "GBP",
      "total": "214.00",
      "lineCount": 2,
      "capturedAt": "2026-07-28T23:34:02Z",
      "createdAt": "2026-07-28T23:34:03Z",
      "lines": [
        { "sku": "TR-1180", "name": "Ridgeline 40L Pack", "unitPrice": "189.00", "qty": 1 },
        { "sku": "TR-2040", "name": "Trail Sock",         "unitPrice": "12.50",  "qty": 2 }
      ]
    }
  ],
  "cursor": "e1.v84157.9f3a…",
  "hasMore": false
}
```

Keep the `cursor` and send it back as `since` next time. It is opaque — do not parse it — and it
is bound to your tenant, so a cursor from anywhere else is refused rather than decoded.

**Money is a string, deliberately.** `"total": "214.00"` rather than `214.00`. A JSON number
becomes a floating point value in most languages, and you should not have to discover binary
floating point to get 10p right on an order you are about to create.

**Names and prices are what the associate saw**, captured at the moment the basket was built. They
do not follow later catalog changes, and the SKU may not even exist by the time you read it. That
is on purpose: the record says what the customer was quoted.

The same rules as the catalog delta endpoint apply, and for the same reasons:

- **At-least-once.** A handoff may be returned more than once. Upsert on `id`.
- **`version` only increases.** Order by it; it is what the cursor tracks.
- **A handoff can come back with a higher `version`** if its status changes. Upsert on `id`
  and you get this for free.

A handoff appears a few seconds after it was sent rather than instantly. That gap is deliberate:
it guarantees that a basket written at the same moment as your read cannot slip behind your cursor
and be missed.

### Fetching one by id

When you know which handoff you want — because a notification just told you, or because you stored
the id earlier — fetch it directly:

```
GET https://<your sales endpoint>/v1/sales/<handoffId>
Authorization: Bearer <your API key>
```

The reply is one handoff — the same object the feed returns inside its `sales` array, unwrapped:

```json
{
  "id": "9f2c8a1e-4d77-4b0e-9c2a-1f5b6d8e0a33",
  "clientRef": "0f9c4b2a-77d1-4e93-8c6f-2ab5d90e1c47",
  "version": 84157,
  "status": "open",
  "handoffCode": "K72Q9",
  "siteId": "northgate",
  "currency": "GBP",
  "total": "214.00",
  "lineCount": 2,
  "capturedAt": "2026-07-29T09:14:02Z",
  "createdAt": "2026-07-29T09:14:03Z",
  "lines": [
    { "sku": "TR-1180", "name": "Ridgeline 40L Pack", "unitPrice": "189.00", "qty": 1 },
    { "sku": "TR-2040", "name": "Trail Sock",         "unitPrice": "12.50",  "qty": 2 }
  ]
}
```

There is no `cursor` and no `hasMore` in this reply, because a fetch is not a position in the feed.

You can also look one up by the reference we sent you, if that is what you recorded:

```bash
curl -H "Authorization: Bearer SAK1.…" \
  https://your-sales-endpoint/v1/sales/ref:0f9c4b2a-77d1-4e93-8c6f-2ab5d90e1c47
```

A failure looks like this, and is the same body whether the handoff never existed or belongs to
somebody else:

```json
{ "error": "not_found", "message": "no handoff with that id" }
```

**This does not move your cursor.** Fetching a handoff because you were notified about it, and
polling the feed, are completely independent — being nudged about one basket cannot cause you to
skip others, and a handoff you fetched this way still arrives in the feed in its turn. If you keep
your own record, upsert on `id` and the two paths agree.

Unlike the feed, a fetch is not held back by the few-second delay described above: there is no
cursor here to advance, so there is nothing to protect. A handoff is fetchable the moment we have
it, which is why a notification is worth acting on immediately.

An id we do not hold — including one belonging to another tenant — returns `404`, never `403`.

### Looking one up by the code the associate read out

This is the one your till needs most.

The associate builds a basket on the floor and reads out a five-character code
— `V2 2GL`. That code is what actually travels between the two people. To turn it
into the basket:

```
GET https://<your sales endpoint>/v1/sales?code=V22GL
Authorization: Bearer <your API key>
```

Type it however it arrives — `v22gl`, `V2 2GL`, with or without the space. The
reply is the same `{"sales": [...]}` shape as the feed.

**It can return more than one, and your till must handle that.** The code is minted
on the handset, which may be offline, so it is checked for clashes only against
that device's own codes for that day. Five characters from a 32-letter alphabet is
33 million combinations — inside a single shop-day a clash is about one in ten
thousand, but across a year of trading it is a certainty.

So we return every match rather than guessing, newest first, and we search only the
**last 24 hours** — which is the code's own lifetime, since it expires at end of
shift on the handset. If you get two, show the operator the associate name, the
time and the total and let them pick. Ringing up the wrong customer's basket is not
a mistake anyone notices until the money has moved.

**A code lookup does not move your cursor.** Serving a customer at the counter must
never cause your till to skip handoffs it has not yet processed. The two are
completely independent.

### Being told when one arrives (optional)

Polling every minute is a perfectly good way to run this. If you would rather be nudged, register
a URL on the Connectors page and we will POST to it when a handoff lands.

**The notification is a nudge and nothing more.** Here is the entire body:

```json
{
  "type": "handoff.created",
  "tenantId": "…",
  "handoffId": "9f2c…",
  "clientRef": "0f9c…",
  "version": 84157,
  "occurredAt": "2026-07-28T23:34:03Z",
  "note": "This is a notification only. Fetch the handoff itself from GET /v1/sales?since=<cursor>, which is the source of truth. No payment was taken."
}
```

No lines, no total, no prices, no handoff code. That is on purpose, and it is the most important
thing on this page: **you cannot build a draft order from the notification, so you cannot
accidentally depend on it.** What it does carry is `handoffId` — hand that straight to
`GET /v1/sales/<handoffId>` above, and you have the basket. Notifications get lost — a deploy, a firewall rule, a bad hour for
your load balancer — and when one does, the cursor still finds the handoff. Nothing is lost. It
also means a notification sitting in a proxy log tells nobody what your customer was buying.

### Checking the signature

Every request carries these headers:

```
X-StoreAssist-Event:     handoff.created
X-StoreAssist-Delivery:  <a unique id for this attempt>
X-StoreAssist-Timestamp: 1753660800
X-StoreAssist-Signature: v1=<hex hmac-sha256>
```

The signature is computed over the timestamp, a full stop, and then the **raw body bytes** —
before any JSON parsing, because re-serialising changes the bytes:

```python
import hmac, hashlib, time

def verify(secret, headers, raw_body):
    ts = int(headers["X-StoreAssist-Timestamp"])
    if abs(time.time() - ts) > 300:          # reject anything older than 5 minutes
        return False
    expected = "v1=" + hmac.new(
        secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, headers["X-StoreAssist-Signature"])
```

Two details worth keeping:

- **Check the timestamp.** It is inside the signed material, so a captured request cannot be
  replayed under a fresh one — but only if you actually enforce the window.
- **Compare in constant time.** A byte-at-a-time comparison leaks how much of a forged signature
  was correct, which is enough to construct one.

The secret is shown **once**, when you save the endpoint. Saving it again mints a new one.

### What we will and will not connect to

- **HTTPS only.** We will not send your customers' baskets over an unencrypted connection.
- **We never follow redirects.** Register the final URL. A 3xx is recorded as a failure with a
  message saying so.
- **Public addresses only.** An address inside our own network is refused.
- **Ten seconds.** A slower endpoint is treated as a failure.

### When delivery fails

Six attempts over roughly two and a half hours, spacing out each time, then we stop and mark it
undelivered. Any 2xx counts as success. A `410 Gone` stops us immediately and disables the
endpoint — send that if you are retiring a URL.

The Connectors page shows the last success, the last failure and its reason, and how many are
undelivered. **Undelivered is not lost.** Every handoff is still in the feed, and a cursor read
picks it up whether we ever managed to tell you about it or not.

---

## 11. Monitoring

New connectors are created **disabled**. Nothing is fetched until you enable one, so you can save a
draft profile safely. The default schedule is every 5 minutes.

On the **Connectors** page in the dashboard you will see, per connector:

- its status — `disabled`, `active`, or `error`
- **last run** — when the worker last attempted a sync, successful or not
- **last error** — the error text from the most recent failed run, shown verbatim, exactly as §12
  lists it

The last error is written on every run, so a run that succeeds clears it. Use it as your first stop:
the messages name the row number and the field.

We also retain the most recent raw payload from each endpoint, so when a mapping is wrong we can
replay it and see what your API actually sent, rather than asking you to re-run a load against your
production system.

---

## 12. Troubleshooting

These are the actual messages the sync worker produces. Values in angle brackets are substituted at
runtime.

### The profile will not save or the connector is skipped

| Message | What to change |
|---|---|
| `parse profile: <detail>` | The profile is not valid JSON. Check for a trailing comma or an unquoted key. |
| `unknown strategy "<x>" (known: snapshot_diff, push)` | Check the spelling. The dashboard sets this field for you when you pick a connector type. |
| `strategy "<x>" is planned but not implemented yet` | `polled_delta` and `file` are on the roadmap. Use `snapshot_diff` or `push` today. |
| `push connectors have no source.baseUrl — we never call you, your system calls us` | You have a pull profile with the strategy changed. A push profile has no `source` and no paths; its endpoints live under `push`. |
| `source.baseUrl is required` | Add `source.baseUrl`. |
| `at least one of catalog/stock endpoints is required` | Add a `catalog` or `stock` block. |
| `catalog.path is required` / `stock.path is required` | Add `path` to that endpoint. |
| `catalog.map is required` / `stock.map is required` | Add a non-empty `map` to that endpoint. |

### Fetching from your API

| Message | What to change |
|---|---|
| `bad endpoint url: <detail>` | `baseUrl` + `path` do not form a valid URL. Check for a missing scheme or a stray space. |
| `vendor fetch: <detail>` | We could not reach your endpoint at all: DNS, TLS, firewall, or a response slower than 30 seconds. Check that our worker is allowed through. |
| `vendor returned <code> for <path>` | Your API answered with a non-200 status. `401`/`403` means credentials or scope; `404` means the path is wrong. |
| `pagination did not terminate after 10000 pages` | Your endpoint keeps returning rows for every page number. Check that it honours the page parameter and returns an empty list past the end. |

### Reading the response body

| Message | What to change |
|---|---|
| `vendor payload is not an array: <detail>` | You left `itemsField` out but the response is an object. Set `itemsField` to the field holding the array. |
| `vendor payload is not an object: <detail>` | You set `itemsField` but the response is an array. Remove `itemsField`. |
| `vendor payload has no "<field>" field` | `itemsField` names a field the response does not contain. Check spelling and case. |
| `"<field>" is not an array: <detail>` | `itemsField` names a field that is not a list of rows. Point it at the array. |

### Mapping a row

Row errors are prefixed with `catalog row <n>:` or `stock row <n>:`, where `<n>` is the zero-based
position in the fetched list, and field errors add `field <name>:`.

| Message | What to change |
|---|---|
| `unknown transform "<x>"` | Use one of: `none`, `string`, `number`, `int`, `millis`, `seconds`, `bool`. |
| `not a number: "<value>"` | The string at that path does not parse as a number. Point the path at the numeric field. |
| `cannot coerce <type> to number` | The value is an object, array or boolean. Point the path at a leaf value. |
| `not a timestamp: "<value>"` | The string is not RFC 3339. Have your API emit `2026-07-28T09:30:00Z`, or send Unix seconds/millis as a number. |
| `cannot coerce <type> to millis` | The value is neither a number nor a string. Check the path. |
| `cannot coerce <type> to bool` | Only booleans, strings and numbers can become booleans. |
| `row has no sku` | The catalog row's `sku` mapping produced nothing or an empty string. Check the path and that every row carries it. |
| `row <sku> has no name` | Same, for `name`. |
| `stock row has no sku` | The stock row's `sku` mapping produced nothing. |
| `stock row <sku> has no site` | The stock row's `siteId` mapping produced nothing. |
| `vendor site "<code>" is not in siteMap` | Add that location code to `siteMap`, pointing at an existing Floor Lookup site id. See §6. |
| `stock row <sku>/<site> carries no source asOf` | Map `asOf` to your snapshot time with `transform: "millis"`. We will not substitute our own clock. See §7. |

### Writing

| Message | What to change |
|---|---|
| `upsert item <sku>: <detail>` | A database error while storing a product. Contact support with the message. |
| `upsert stock <sku>/<site>: <detail>` | As above, for a stock row. |
| `soft-delete missing items: <detail>` | As above, while marking removed products. |

A failed run leaves the previously synced data in place. The app keeps serving the last good
snapshot, with `asOf` ageing honestly, until the next successful run.
