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.
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:
{"sku":"A-1","deleted":true}.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."changed": 0 and
disturbs nothing — this is also the recommended way to verify your work.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.
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:
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.
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.
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.
POST https://<your-hub>/v1/push/catalog
POST https://<your-hub>/v1/push/stock
Authorization: Bearer SAK1.…
content-type: application/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:
{ "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 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:
"mode": "snapshot".declaredRowCount matches the rows that actually arrived.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.
{ "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.
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. |
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.
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.
{ "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" } ] }
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.
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.
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.
These are the only field names the map object understands. Anything else in your rows is ignored.
| 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:
"deleted": true. A push connector can opt into
snapshot behaviour, but it is off by default and guarded — see §4.| 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.
siteMap, and why an unmapped site is a hard errorsiteMap 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.
asOf is mandatory on stock rowsThe 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.
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.
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.
| 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.
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.0EANs yields the whole arrayPaths 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.
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:
2026-07-28T09:15:00Z). Surrounding whitespace is trimmed.
Any other string format fails with not a timestamp.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.
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:
222 or 223 opens Vendor Jacket, which shows 0 at Main StreetIf 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.
| 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.
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.
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.
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.
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:
id.version only increases. Order by it; it is what the cursor tracks.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.
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.
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.
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.
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:
The secret is shown once, when you save the endpoint. Saving it again mints a new one.
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.
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:
disabled, active, or errorThe 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.
These are the actual messages the sync worker produces. Values in angle brackets are substituted at runtime.
| 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. |
| 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. |
| 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. |
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. |
| 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.