Floor Lookup

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:

{"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

{
  "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:

{ "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

{ "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:

{
  "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:

{
  "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:

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:

{ "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:

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

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

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

{ "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:

{ "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:

{ "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:

{ "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:

"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:

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

leave itemsField out. If it returns:

{ "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:

{
  "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

{
  "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

{
  "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:

{
  "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):

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:

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

By id:

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

3. You get the whole thing:

{
  "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:

{
  "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:

{
  "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:

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:

{ "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:

{
  "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:

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.