A read-only HTTP API over your own inventory. One bearer key, 24 JSON collections, and the same field names your backup file uses — so a script, a dashboard, or a Zapier step can read your stock without anyone re-keying it.
The Stockout API hands your own data back as JSON over plain HTTP. Point anything that can send a header at it — a script, a spreadsheet job, a warehouse dashboard, or the generic HTTP action in Zapier, Make or n8n — and you get the same snake_case fields the backup file uses.
v1 is read-only. GET and HEAD are the only methods; there is no endpoint that creates, updates or deletes anything, and nothing you can send will change your stock. Writing through the API is a later phase — see Versioning.
Use the first form. The second is the same function on Supabase’s shorter function hostname and behaves identically — the path parser strips either prefix — so pick one and stay on it.
https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/<collection> <- use this one
https://puhxxxsdvurwavbmuhhd.functions.supabase.co/api/v1/<collection>
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
"https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/items?limit=1&pretty=1"
{
"data": [
{
"id": "3f6a1c88-2d54-4b31-9d0e-7a1b2c3d4e5f",
"location_id": "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b",
"name": "M8 Hex Bolt 40mm",
"sku": "BOLT-M8-40, 5012345678900",
"category": "Fasteners",
"quantity": 1840,
"price_at_cost": 0.07,
"price_at_retail": 0.19,
"low_stock_threshold": 250,
"low_stock_is_percent": false,
"near_capacity_threshold": 80,
"near_capacity_is_percent": true,
"max_capacity": 2500,
"description": "Zinc-plated, DIN 933.",
"storage_location": "Aisle 3 / Bin B12",
"tracking_type": "Quantity",
"selected_unit": null,
"created_at": "2026-03-02T16:40:11.004921+00:00",
"updated_at": "2026-09-11T09:14:02.117483+00:00",
"is_deleted": false
}
],
"next_cursor": "MjAyNi0wOS0xMVQwOToxNDowMi4xMTc0ODMrMDA6MDB8M2Y2YTFjODgtMmQ1NC00YjMxLTlkMGUtN2ExYjJjM2Q0ZTVm",
"has_more": true
}
org_id is never returned — it would be the same value on every row. Your team’s id is in /v1/account.https://stockoutinventory.com/openapi.json into Postman, Insomnia or an SDK generator.2026-09-11T09:14:02.117483+00:00. Money and quantities are JSON numbers, not strings.Every request carries an API key in the Authorization header. Keys look like sto_live_ followed by 64 hex characters. There is no other way in — a Supabase session, an anon key or a plain apikey header will not authenticate you.
Authorization: Bearer sto_live_9f3ad2c17b414e8e9a355c0b1d2e3f4471a6c8b2d0e4f6a8c1b3d5e7f9a0b2c4
Key management lives in the app, not on this site. It is the same screen on all three surfaces, and only Owners and Admins can see it.
sto_live_9f3a) and when it was last used.Tap Revoke next to a key. It stops working on the next request, permanently — revoked keys stay in the list, greyed out, so the audit trail survives. A team can hold 25 active keys at a time; revoke one to free a slot.
| Status | Meaning |
|---|---|
401 unauthorized | The header is missing or malformed, or the key is unknown, revoked or past its expiry. The hint reads “Send ‘Authorization: Bearer sto_live_…’ with a key that has not been revoked or expired.” |
402 no_active_subscription | “This team does not have an active Stockout plan.” The key is valid but the plan is not. Renew the subscription and the same key starts working again — nothing needs re-minting. |
503 service_unavailable | “We could not verify your API key. Please retry shortly.” The check itself failed on our side. Transient; retry. It is deliberately not a 401, so a blip on our side never looks like a revoked key. The same status with “This collection needs a database migration…” means the opposite — it will not clear on retry until this Stockout project’s database schema is brought up to date. |
401 for every collection name — real and invented alike. You cannot probe what exists without a key.Never put a sto_live_ key in a web page, in front-end JavaScript, in a mobile app, or in a public repository. Anything shipped to a browser or a phone is readable by whoever holds it. The API sends Access-Control-Allow-Origin: *, so a browser can call it — that is there for server-side tools and local testing, not an invitation to embed your key in a page.
One key reads every item, price, cost, customer, supplier and work order your team has, at every location. Treat it exactly like a password:
Costs and retail prices are in /v1/items and customer contact details are in /v1/customers, so a leaked key is a commercial problem as well as a privacy one. If you need a feed for something untrusted, put your own server in front of the API and hand out only the fields that job needs.
Everything your team has stocked is readable. A handful of things are not, and they are missing by decision rather than by oversight — other people’s personal data, billing plumbing, and internal bookkeeping that would only mislead an integration.
| Not returned | Why, and what you get instead |
|---|---|
| Pending team invites | An invite holds the email address of someone who has not joined your team yet — third-party personal data, and not yours to hand to an integration. Members who have accepted are in /v1/account under members. |
| The API keys themselves | There is no collection of keys, not even a “safe” projection of one: the table stores key hashes, and a list of key ids would turn one leaked key into a map of all the others. The calling key describes itself in /v1/account under key — name, prefix, scopes, created and last used. |
| Stripe identifiers | The customer and subscription handles are billing plumbing: no automation value, and a real abuse surface if they leak. /v1/account.plan carries the parts that matter — plan, status, device cap, period end, and whether it is set to cancel. |
| Device identifiers | The internal device row id and the hardware device id are both omitted. /v1/account.devices still lists each device’s label, platform, last-seen time and created date, which is what a seat-usage report actually needs. |
settings and sync_cursor on /v1/integrations | settings is free-form storage a future integration could stash a third-party token in, so it can never be projected here. sync_cursor is our own bookkeeping and means nothing outside the sync engine. |
app_version | Which build of an app last touched a row is internal release bookkeeping. It is not part of your data, and it is returned nowhere. |
For the same reason the “who did this” columns are never projected: created_by, invited_by and their siblings hold raw account ids from our auth system, which identify a person without naming one. org_id is left off every row too, but only because it would be the same value on all of them — it is in /v1/account.
New fields and new collections can appear without a version bump, so a gap is not permanent. Tell us what the integration has to do and why — get in touch.
GET returns data. HEAD runs the identical request and returns the same status and headers with no body — handy for uptime checks and curl -I. OPTIONS is the CORS preflight. Every other method is 405 method_not_allowed with the hint “This API is read-only; use GET or HEAD.”
GET /v1 <- discovery: the collection and endpoint lists
GET /v1/account <- your team, plan, members, devices, locations
GET /v1/<collection> <- a page of rows
GET /v1/<collection>/<uuid> <- one row by id
Anything deeper is 404 not_found (“Expected /v1/<collection> or /v1/<collection>/<id>.”). An id that is not a uuid is 400 bad_request before we ever touch the database.
There are exactly three. Every successful body is wrapped in data, so a list and a single row never have to be told apart by guessing.
// list
{ "data": [ { ... }, { ... } ], "next_cursor": "<opaque string or null>", "has_more": true }
// single row
{ "data": { ... } }
// error (plus "request_id" on a 500)
{ "error": "bad_param", "hint": "'locationid' is not a supported query parameter for /v1/items." }
| Header | Notes |
|---|---|
Content-Type | Always application/json. |
Cache-Control | no-store. The body is specific to your team — do not let a shared cache near it. |
Vary | Authorization, for the same reason. |
X-Content-Type-Options | nosniff. |
X-RateLimit-Limit | Requests allowed per minute for this key. See Rate limits. |
X-RateLimit-Remaining | How many are left in the current minute. |
X-RateLimit-Reset | Unix epoch seconds at which the current window resets. |
Retry-After | Seconds to wait. Sent on a 429 only. |
Access-Control-Allow-Origin | *. The X-RateLimit-* trio and Retry-After are exposed to browser JavaScript; without that a cross-origin caller could see the 429 but not the headers telling it when to retry. |
The X-RateLimit-* trio is on every response decided after your key is accepted — including the 400s, the 404s and a 500. It is absent from 401, 402, 405, 503 and the 404 for a path with too many segments, all of which are decided before the limiter ever runs.
Responses are compact by default. Add ?pretty=1 for 2-space indentation while you are exploring; leave it off in production.
checklists.items_json and label_templates.elements_json are strings that contain JSON, not nested objects. That is deliberate: they come back byte-identical to what the backup file holds, so a value round-trips through the API and back into the app unchanged. Parse them yourself.
const res = await fetch(`${BASE}/v1/checklists?limit=1`, { headers: AUTH });
const body = await res.json();
body.data[0].items_json; // '[{"text":"Check oil","done":false}]' <- a string
JSON.parse(body.data[0].items_json); // [ { text: 'Check oil', done: false } ] <- parse it
next_cursor until has_more is false.Lists return at most limit rows (1–200, default 50) ordered by the collection’s sort column ascending, with id breaking ties. When more rows exist you get has_more: true and an opaque next_cursor; pass it back as ?cursor= to continue. When has_more is false, next_cursor is null and you are done.
The sort column is updated_at everywhere except /v1/stock-movements, which has no updated_at and sorts on ingested_at — the moment the movement reached the server, not the created_at business time the device stamped on it.
Two rows at a time through /v1/folders, a small collection, so the rows fit whole.
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
"https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/folders?limit=2&pretty=1"
{
"data": [
{
"id": "3f6a1c88-2d54-4b31-9d0e-7a1b2c3d4e5f",
"location_id": "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b",
"name": "Winter service kit",
"updated_at": "2026-09-11T09:14:02.117483+00:00",
"is_deleted": false
},
{
"id": "c07d5e12-9b84-4f60-83aa-11e2c6d40b97",
"location_id": "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b",
"name": "Reorder next week",
"updated_at": "2026-09-11T11:48:31.902004+00:00",
"is_deleted": false
}
],
"next_cursor": "MjAyNi0wOS0xMVQxMTo0ODozMS45MDIwMDQrMDA6MDB8YzA3ZDVlMTItOWI4NC00ZjYwLTgzYWEtMTFlMmM2ZDQwYjk3",
"has_more": true
}
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
"https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/folders?limit=2&pretty=1&cursor=MjAyNi0wOS0xMVQxMTo0ODozMS45MDIwMDQrMDA6MDB8YzA3ZDVlMTItOWI4NC00ZjYwLTgzYWEtMTFlMmM2ZDQwYjk3"
{
"data": [
{
"id": "5a41b9d3-6c07-4e28-8f15-90ab3c7d6e21",
"location_id": "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b",
"name": "Damaged / write-off",
"updated_at": "2026-09-11T13:02:55.410882+00:00",
"is_deleted": false
}
],
"next_cursor": null,
"has_more": false
}
const BASE = "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api";
const AUTH = { Authorization: `Bearer ${process.env.STOCKOUT_API_KEY}` }; // server-side only
async function fetchAll(collection, params = {}) {
const rows = [];
let cursor = null;
for (;;) {
const q = new URLSearchParams({ ...params, limit: "200" });
if (cursor) q.set("cursor", cursor);
const res = await fetch(`${BASE}/v1/${collection}?${q}`, { headers: AUTH });
if (res.status === 429) { // over the limit: wait it out and retry
const wait = Number(res.headers.get("Retry-After") || 1);
await new Promise(r => setTimeout(r, wait * 1000));
continue;
}
if (!res.ok) {
const err = await res.json();
throw new Error(`${res.status} ${err.error}: ${err.hint}`);
}
const page = await res.json();
rows.push(...page.data);
if (!page.has_more) return rows;
cursor = page.next_cursor;
}
}
const items = await fetchAll("items", { location_id: "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b" });
next_cursor back exactly as it arrived — anything else is 400 bad_cursor (“Pass back the next_cursor value exactly as returned.”).updated_at. Slicing that group on the timestamp alone loses every row after the page boundary — silently.limit outside 1–200, or not a whole number, is 400 bad_request. limit on a single-row URL is 400 bad_param — there is nothing to page.| Parameter | Values | What it does |
|---|---|---|
| limit | 1–200, default 50 | How many rows per page. Anything else is 400 bad_request. |
| cursor | a next_cursor | Continue from where the last page stopped. See Pagination. |
| updated_since | ISO-8601 timestamp | Only rows whose sort column is strictly after this moment. A date on its own (2026-09-01) is accepted. Anything unparseable is 400 bad_request. |
| include_deleted | true / false / 1 / 0 | Include soft-deleted rows. Accepted but inert on /v1/stock-movements, /v1/item-external-refs and /v1/integrations, which have no is_deleted column. See Deleted rows. |
| pretty | 1 | Indent the JSON. |
updated_since filters the same column the collection sorts on — updated_at everywhere except /v1/stock-movements, where it means “ingested since” — see the ledger. On a single-row URL only include_deleted, pretty and that collection’s own filters are accepted.
Each collection accepts only the filters listed below. They are all exact matches except sku.
| Filter | Applies to | Rules |
|---|---|---|
| location_id, item_id, kit_id, equipment_id, checklist_id, folder_id, supplier_id, work_order_id, customer_id | the collections listed under All collections | Exact match on a uuid. A value that is not a uuid is 400 bad_request (“<param> must be a uuid.”). |
| sku | /v1/items | Case-insensitive substring match against the whole sku field, so it finds an item that carries several codes. %, _ and \ are escaped for you and match literally. * is rejected: a value containing one is 400 bad_request (“sku cannot contain '*'.”). The value is cut at 100 characters. |
| source | /v1/stock-movements | Exact match. 1–64 characters, letters, digits, ., - or _; anything else is 400 bad_request. |
| provider | /v1/item-external-refs | Same rule as source. |
| status | /v1/work-orders | One of draft, open, in_progress, on_hold, completed, cancelled. Anything else is 400 bad_request. |
* is not onesku is already a substring match, so ?sku=BOLT does what *BOLT* would have done. An explicit * is refused with 400 bad_request rather than accepted: between this API and the database every * in a text filter is rewritten to a %, and that rewrite is not escape-aware — so ?sku=A*B could only ever have matched the rows containing a literal A%B, never the ones you asked for. A loud 400 beats a quietly wrong answer. %, _ and \ need no thought from you: they are escaped server-side and match themselves.
Any query parameter that is not on the universal list and not one of that collection’s own filters returns 400 bad_param. A misspelled ?locationid= that was quietly ignored would hand you the whole team’s catalogue while looking like a working filter — so it fails loudly instead. The same goes for a filter on the wrong collection: /v1/suppliers?location_id=… is a 400, because suppliers are team-wide and have no location.
Combine updated_since with include_deleted=true and you have an incremental sync: ask for everything that changed since your last run, apply it, and remember the newest updated_at you saw. Rows arrive oldest first, so the last row of the last page is the new watermark.
// Runs on a schedule. `since` is whatever you stored at the end of the previous run.
let since = await loadWatermark(); // e.g. "2026-09-11T09:14:02.117483+00:00"
const changed = await fetchAll("items", { updated_since: since, include_deleted: "true" });
for (const row of changed) {
if (row.is_deleted) await removeLocally(row.id);
else await upsertLocally(row);
}
if (changed.length) await saveWatermark(changed[changed.length - 1].updated_at);
Re-running with a slightly old watermark is safe — you simply re-apply rows you already have. Running with a watermark from the future is not: updated_since is strictly greater-than, so anything stamped at exactly that moment is skipped. Always store a timestamp you actually received, never Date.now().
Deleting something in Stockout sets is_deleted to true and bumps updated_at; the row stays. The API returns is_deleted on every collection that has it, but hides deleted rows by default. Add include_deleted=true to see them.
| Request | Returns |
|---|---|
/v1/items | Live items only. |
/v1/items?include_deleted=true | Live and deleted items, each with its is_deleted flag. |
/v1/items/<id> | 404 not_found if that item has been deleted. |
/v1/items/<id>?include_deleted=true | The row, deleted or not. |
This is the only way to keep an external copy honest. Poll with updated_since and include_deleted=true: a row that comes back with is_deleted: true tells you to remove your copy. Without it, a deleted row just stops appearing, and a mirror has no way to tell that apart from “nothing changed”.
/v1/stock-movements is an append-only ledger, and /v1/item-external-refs and /v1/integrations keep no deleted state either, so none of the three has an is_deleted field — a row removed from one of them simply stops appearing. include_deleted is still accepted there — it just has nothing to do — so a client that appends it to every request does not break.
A value other than true, false, 1 or 0 is 400 bad_request (“include_deleted must be true or false.”).
error string is the contract; the hint is for humans.Every failure has the same body: a stable error code you can branch on, and a hint sentence meant for whoever is reading your logs. Branch on error, never on the wording of hint — hints get reworded, codes do not.
HTTP/1.1 400 Bad Request
{
"error": "bad_param",
"hint": "'locationid' is not a supported query parameter for /v1/items."
}
| Status | Code | When |
|---|---|---|
| 400 | bad_request | A value was malformed — a non-uuid id, a limit outside 1–200, an updated_since that is not ISO-8601, an include_deleted that is not true/false, or a filter value of the wrong shape. |
| 400 | bad_param | A query parameter this collection does not accept. Nothing is ever silently ignored — a typo’d ?locationid= that quietly returned the whole collection would be the worst failure mode for an integration. |
| 400 | bad_cursor | The cursor was not produced by this API, or it has been re-encoded in transit. Pass next_cursor back byte-for-byte. |
| 401 | unauthorized | Missing, malformed, unknown, revoked or expired key. |
| 402 | no_active_subscription | The team has no active or trialing Stockout plan. Existing keys start working again the moment the plan is renewed. |
| 404 | not_found | No row with that id in your team’s data — or a path with more than two segments after /v1. |
| 404 | unknown_collection | No such collection. The hint lists the valid ones, and every name that is not on that list behaves identically — there is no name that is special. Only authenticated callers ever see this, so the collection list cannot be enumerated without a key. |
| 405 | method_not_allowed | Anything other than GET, HEAD or OPTIONS. v1 is read-only. |
| 429 | rate_limited | Over the per-key or per-team limit. See Rate limits. |
| 500 | server_error | Our bug. The body carries a request_id; quote it to support. The underlying message is logged on our side only and is never echoed back. |
| 503 | service_unavailable | The key lookup or the plan check itself failed. Transient — retry shortly. Deliberately not a 401: a database blip must not tell every customer at once that their key is invalid. This status is also returned when the project’s database schema is older than the deployed API and a column the collection needs is missing — the hint says so, and retrying will not help until the schema is updated. |
If something breaks on our side you get a request_id and nothing else. The underlying database message names tables, columns and constraints and reflects your own input back at you, so it is written to our logs and never to the response.
{
"error": "server_error",
"hint": "Something went wrong on our side. Quote the request_id if you contact support.",
"request_id": "6b1f0c2e-9a47-4d83-8e15-2f7c40ab9d63"
}
const res = await fetch(`${BASE}/v1/items?limit=50`, { headers: AUTH });
if (!res.ok) {
const err = await res.json();
switch (err.error) {
case "rate_limited": // wait Retry-After seconds, then repeat the same request
return retryAfter(res.headers.get("Retry-After"));
case "unauthorized": // the key is gone: stop, alert a human, do not retry
throw new Error("Stockout API key is invalid or revoked");
case "no_active_subscription":
throw new Error("The Stockout subscription has lapsed");
case "service_unavailable": // transient on our side
case "server_error": // err.request_id is worth logging
return retryWithBackoff();
default: // every 400 is a bug in the request: fix the call
throw new Error(`${res.status} ${err.error}: ${err.hint}`);
}
}
429 after Retry-After, and 500 / 503 with exponential backoff.400 — the request itself is wrong and will fail identically forever.401 or 402 in a loop. Both need a person: a new key, or a renewed plan.| Bucket | Per minute | Per day |
|---|---|---|
| Per key | 60 | 10,000 |
| Per team (all keys together) | 120 | 50,000 |
Both buckets are checked on every request. The team bucket exists so that minting extra keys cannot multiply your quota; for the same reason a team may hold at most 25 active keys.
Windows are fixed, not sliding: the minute counter resets at the top of each minute and the daily counter at midnight UTC. X-RateLimit-Reset tells you exactly when, in unix epoch seconds.
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 57
X-RateLimit-Reset: 1789239600
Go over and you get 429 rate_limited with a Retry-After in seconds. Wait that long and repeat the identical request.
HTTP/1.1 429 Too Many Requests
Retry-After: 34
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1789239600
{
"error": "rate_limited",
"hint": "60 requests per minute per API key (10000/day). Retry in 34s."
}
limit=200. One request for 200 rows costs the same as one request for 1.updated_since instead of re-reading whole collections. A quiet minute then costs one request per collection.X-RateLimit-Remaining and slow down before you hit zero, rather than after.X-RateLimit-* trio describes the per-key, per-minute bucket. A 429 whose hint mentions your team means another key burned the shared allowance.Lists every collection this API version serves, plus endpoints — the two paths that are not collections, so a client that learns the API from this document alone can still find /v1/account. Useful as a health check and to confirm a key works before wiring anything else up. It accepts pretty and nothing else.
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1?pretty=1"
{
"data": {
"version": "v1",
"collections": [
"items",
"locations",
"kits",
"kit-components",
"equipment",
"equipment-log",
"checklists",
"checklist-log",
"folders",
"folder-items",
"suppliers",
"supplier-contacts",
"item-suppliers",
"customers",
"work-orders",
"work-order-materials",
"work-order-equipment",
"work-order-checklists",
"work-order-labor",
"work-order-activity",
"label-templates",
"stock-movements",
"item-external-refs",
"integrations"
],
"endpoints": [
"/v1",
"/v1/account"
],
"docs": "https://stockoutinventory.com/developers.html"
}
}
Everything about the team the key belongs to, composed from several tables rather than exposed as a collection. Accepts pretty; include_deleted is tolerated and ignored. There is no /v1/account/<id> — that is a 404.
{
"data": {
"org": {
"id": "1c8d3b90-4f2a-4c77-9e05-6ab1d2f34c58",
"name": "Northgate Plumbing",
"created_at": "2026-02-14T08:03:44.120031+00:00"
},
"plan": {
"plan": "standard",
"status": "active",
"device_cap": 4,
"current_period_end": "2026-10-01T00:00:00+00:00",
"cancel_at_period_end": false
},
"members": [
{ "role": "owner", "email": "dana@northgate.example", "created_at": "2026-02-14T08:03:44.120031+00:00" },
{ "role": "user", "email": "sam@northgate.example", "created_at": "2026-03-01T14:22:09.771204+00:00" }
],
"devices": [
{ "label": "Warehouse tablet", "platform": "Android", "last_seen_at": "2026-09-11T12:55:02.339115+00:00",
"created_at": "2026-02-14T09:10:00.000000+00:00" }
],
"locations": [
{ "id": "8b2e40d7-1f6c-4a93-b0d1-52c9e7a4188b", "name": "Main Warehouse", "is_active": true }
],
"key": {
"name": "Zapier - reorder sheet",
"key_prefix": "sto_live_9f3a",
"scopes": "read",
"created_at": "2026-08-30T10:11:12.000000+00:00",
"last_used_at": "2026-09-11T13:40:55.208844+00:00"
},
"rate_limit": { "per_minute": 60, "per_day": 10000, "remaining": 57 }
}
}
| Field | Notes |
|---|---|
| org | Your team’s id, name and creation date. The id is the one value every row in every collection shares, which is why it is not repeated on each row. |
| plan | plan and status come from billing; status is trialing, active, past_due, canceled or none. device_cap is how many devices the plan is billed for. The API only answers at all while the status is trialing or active. |
| members | One entry per teammate, oldest first: role (owner, admin, user or viewer), email and when they joined. Internal user ids are never included. |
| devices | Registered devices, oldest first, with label, platform, last_seen_at and created_at. Device identifiers are never included. |
| locations | Active and inactive locations, by name, but not deleted ones. Handy for turning a name into the location_id other endpoints filter on. |
| key | The calling key only: its name, its 13-character prefix, its scopes (read in v1), and when it was created and last used. No other key is ever visible, and no key’s secret is stored anywhere to be returned. |
| rate_limit | The allowance for this key and how much of this minute is left. |
GET /v1/<name> and GET /v1/<name>/<uuid>.Field names are the ones Stockout stores, so they match the backup file exactly. A field marked nullable can come back as JSON null; the rest are always present. org_id is stripped from every row.
| Collection | Pages on | Filters |
|---|---|---|
| /v1/items | updated_at | location_id, sku |
| /v1/locations | updated_at | — |
| /v1/kits | updated_at | location_id |
| /v1/kit-components | updated_at | kit_id, item_id |
| /v1/equipment | updated_at | location_id |
| /v1/equipment-log | updated_at | equipment_id |
| /v1/checklists | updated_at | location_id |
| /v1/checklist-log | updated_at | checklist_id |
| /v1/folders | updated_at | location_id |
| /v1/folder-items | updated_at | folder_id |
| /v1/suppliers | updated_at | — |
| /v1/supplier-contacts | updated_at | supplier_id |
| /v1/item-suppliers | updated_at | item_id, supplier_id |
| /v1/customers | updated_at | — |
| /v1/work-orders | updated_at | location_id, customer_id, status |
| /v1/work-order-materials | updated_at | work_order_id |
| /v1/work-order-equipment | updated_at | work_order_id |
| /v1/work-order-checklists | updated_at | work_order_id |
| /v1/work-order-labor | updated_at | work_order_id |
| /v1/work-order-activity | updated_at | work_order_id |
| /v1/label-templates | updated_at | — |
| /v1/stock-movements | ingested_at | item_id, location_id, source |
| /v1/item-external-refs | updated_at | item_id, provider |
| /v1/integrations | updated_at | — |
Everything you stock, at one location. This is the collection most integrations start with.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. Stable for the life of the item. |
| location_id | uuid, nullable | Location the item is stocked at. Deleting a location does not clear this: the location row and every item at it are both flagged is_deleted and keep their ids, so resolve the id against /v1/locations?include_deleted=true rather than waiting for a null. |
| name | string | Item name. May be an empty string. |
| sku | string | One or more codes joined with ", " — an item can carry several barcodes. The sku filter matches any substring of this whole field. |
| category | string, nullable | Free-text category. |
| quantity | number | Quantity on hand. Decimal, because weight/length/volume items are not whole numbers. |
| price_at_cost | number | What you pay per unit. |
| price_at_retail | number | What you sell for per unit. |
| low_stock_threshold | number | At or below this the item reads Low. A percentage of max_capacity when low_stock_is_percent is true. |
| low_stock_is_percent | boolean | Whether low_stock_threshold is a percentage rather than a count. |
| near_capacity_threshold | number | At or above this the item reads Near Capacity. A percentage of max_capacity when near_capacity_is_percent is true. |
| near_capacity_is_percent | boolean | Whether near_capacity_threshold is a percentage rather than a count. |
| max_capacity | number | How much the storage spot holds. 0 means capacity is not tracked. |
| description | string, nullable | Free-text notes. |
| storage_location | string, nullable | Free-text bin, shelf or aisle. |
| tracking_type | string | Quantity, Weight, Length or Volume. |
| selected_unit | string, nullable | Unit label (kg, ft, L…). Used when tracking_type is not Quantity. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The places you stock things. Most other collections carry a location_id pointing here.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. Use it as the location_id filter elsewhere. |
| name | string | Location name. |
| address | string, nullable | Street address. |
| city | string, nullable | City. |
| country | string, nullable | Country. |
| is_active | boolean | Inactive locations stay in the data but are hidden in the apps. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Bundles built out of inventory items. Making or selling a kit moves the component items’ stock.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| location_id | uuid, nullable | Location the kit belongs to. |
| name | string | Kit name. |
| quantity_on_hand | number | Built kits currently on hand. |
| description | string, nullable | Free-text notes. |
| storage_location | string, nullable | Free-text bin, shelf or aisle. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The recipe rows joining a kit to the items it consumes.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| kit_id | uuid | The kit. Matches /v1/kits id. |
| item_id | uuid | The component item. Matches /v1/items id. |
| quantity_needed | number | How many of the item one kit consumes. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Reusable gear that gets checked out and back in rather than consumed.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| location_id | uuid, nullable | Location the gear lives at. |
| name | string | Equipment name. |
| total_quantity | integer | How many units you own. What is available is derived from the check-out log, not stored here. |
| description | string, nullable | Free-text notes. |
| storage_location | string, nullable | Free-text bin, shelf or aisle. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
One row per check-out or check-in. Subtract the check-ins from the check-outs to get what is still out.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| equipment_id | uuid | The equipment. Matches /v1/equipment id. |
| employee_first | string | First name typed at the time. |
| employee_last | string | Last name typed at the time. |
| is_check_out | boolean | true = taken out, false = brought back. |
| quantity | integer | How many units this row moves. |
| occurred_at | timestamp | When it happened, supplied by the app. It can be backdated, so it is never the sort column — page and filter on updated_at. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Reusable checklists your team runs and ticks off.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| location_id | uuid, nullable | Location the checklist belongs to. |
| name | string | Checklist name. |
| description | string, nullable | Free-text notes. |
| items_json | JSON string | The checklist’s steps, as a JSON string — not an object. See JSON held in text fields. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
One row per completed run of a checklist.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| checklist_id | uuid | The checklist. Matches /v1/checklists id. |
| employee_first | string | First name typed at the time. |
| employee_last | string | Last name typed at the time. |
| started_at | timestamp | When the run began, supplied by the app. Never the sort column. |
| completed_at | timestamp | When Complete was tapped, supplied by the app. Never the sort column. |
| completed_count | integer | How many steps were ticked. |
| total_count | integer | How many steps the checklist had at the time. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Named collections that reference other records. A folder holds pointers, never copies.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| location_id | uuid, nullable | Location the folder belongs to. |
| name | string | Folder name. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The pointers inside a folder. Each row names a kind and an id in the matching collection.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| folder_id | uuid | The folder. Matches /v1/folders id. |
| entity_type | string | item, kit, equipment, checklist, label_template, supplier, work_order or customer. |
| entity_id | uuid | The referenced row’s id, in the collection entity_type names. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Your vendor directory. Suppliers are team-wide, not tied to a location.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| name | string | Supplier name. |
| portal_urls | string, nullable | Ordering-portal links, one per line or comma-separated. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
People at a supplier.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| supplier_id | uuid | The supplier. Matches /v1/suppliers id. |
| name | string, nullable | Contact name. |
| phone | string, nullable | Phone number, as typed. |
| string, nullable | Email address, as typed. | |
| role | string, nullable | Job title or role. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The join table saying which suppliers can provide which item.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| item_id | uuid | Matches /v1/items id. |
| supplier_id | uuid | Matches /v1/suppliers id. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Who work orders are done for. Customers are team-wide, not tied to a location.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| name | string | Company or customer name. |
| contact_name | string, nullable | Main contact person. |
| phone | string, nullable | Phone number, as typed. |
| string, nullable | Email address, as typed. | |
| address | string, nullable | Address, as typed. |
| notes | string, nullable | Free-text notes. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Jobs with materials, labour, equipment and checklists hanging off them.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| location_id | uuid, nullable | Location the job belongs to. |
| number | integer | Per-team sequence number, shown in the apps as WO-<number>. |
| title | string, nullable | Short job title. |
| customer_id | uuid, nullable | Matches /v1/customers id. Null for an internal job. |
| status | string | draft, open, in_progress, on_hold, completed or cancelled. |
| priority | string | low, normal, high or urgent. |
| assigned_to | string, nullable | Free-text name of whoever owns the job. |
| scheduled_date | timestamp, nullable | When the job is booked in. |
| due_date | timestamp, nullable | When the job is due. |
| completed_at | timestamp, nullable | When the job was completed, supplied by the app. Never the sort column. |
| amount_charged | number, nullable | What the customer is billed. Feeds the margin in the cost summary. |
| notes | string, nullable | Free-text notes. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Items and kits planned for or consumed by a job. Using or fulfilling materials moves real stock.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| work_order_id | uuid | Matches /v1/work-orders id. |
| entity_type | string | item or kit. |
| entity_id | uuid | The referenced row’s id, in the collection entity_type names. |
| qty_planned | number | How much the job expects to use. |
| qty_used | number | How much has actually been pulled from stock. |
| unit_cost | number | Cost per unit, snapshotted when the line was added. |
| unit_price | number | Retail per unit, snapshotted when the line was added. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Gear reserved for a job.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| work_order_id | uuid | Matches /v1/work-orders id. |
| equipment_id | uuid | Matches /v1/equipment id. |
| quantity | integer | How many units are reserved. |
| checked_out | boolean | Whether the gear has actually left the shelf for this job. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Checklists attached to a job.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| work_order_id | uuid | Matches /v1/work-orders id. |
| checklist_id | uuid | Matches /v1/checklists id. |
| completed | boolean | Whether this checklist has been finished for this job. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Time booked against a job. hours × rate is what job costing adds up.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| work_order_id | uuid | Matches /v1/work-orders id. |
| employee | string, nullable | Free-text name. |
| hours | number | Hours booked. |
| rate | number | Hourly rate used for costing. |
| notes | string, nullable | Free-text notes. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The audit trail on a job.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| work_order_id | uuid | Matches /v1/work-orders id. |
| kind | string, nullable | What kind of event this was, as free text. |
| detail | string, nullable | Free-text detail. |
| occurred_at | timestamp | When the event happened, supplied by the app. Never the sort column. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
Saved label designs. Templates are team-wide — they are shared across every location.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| name | string | Template name. |
| width_in | number | Label width in inches. |
| height_in | number | Label height in inches. |
| elements_json | JSON string | The template’s fields, as a JSON string — not an object. See JSON held in text fields. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
| is_deleted | boolean | true once the record has been deleted in the app. See Deleted rows. |
The append-only ledger of every quantity change. Rows here are never edited and never deleted, which is why this is the one collection with no updated_at and no is_deleted.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| item_id | uuid | Matches /v1/items id. |
| location_id | uuid, nullable | Where the movement happened. |
| delta | number | Signed change in quantity. Negative means stock left. |
| reason | string, nullable | Free-text reason written by the app. |
| source | string, nullable | Structured channel tag, e.g. an integration name. Null for movements made in the apps. |
| created_at | timestamp | When the stock actually moved. The device stamps this, so a phone that was offline can write a movement dated yesterday — which is why it is not the sort column. |
| ingested_at | timestamp | When the movement reached the server. Set by the database, never by a device, so it only ever moves forward. This is the sort column, so updated_since and cursor both work on it. |
Maps a Stockout item to the same product in a connected system.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| item_id | uuid | Matches /v1/items id. |
| provider | string | Which system the reference belongs to. |
| external_id | string | That system’s product or variant id. |
| sku | string, nullable | That system’s SKU, when it has one. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
Which external systems your team has connected. Credentials are never part of this collection: settings and sync_cursor are not exposed by the API at all.
| Field | Type | Notes |
|---|---|---|
| id | uuid | Primary key. |
| provider | string | Which system is connected. |
| status | string | connected, error or disconnected. |
| external_id | string, nullable | The provider’s shop or account id. |
| last_sync_at | timestamp, nullable | Last successful sync, or null if it has never run. |
| created_at | timestamp | When the record was first created. |
| updated_at | timestamp | Last time any field changed. Sort column for this collection. |
Every one of these platforms ships a step that makes an arbitrary HTTP request. Because the API is ordinary REST with a bearer header and JSON responses, that step is all you need.
| Platform | Use this step |
|---|---|
| Zapier | Webhooks by Zapier → GET, or a Code step for anything that has to follow cursors. |
| Make | The HTTP module → Make a request. |
| n8n | The HTTP Request node. Its Predefined Credential Type is not needed — add the header yourself. |
| Field | Value |
|---|---|
| Method | GET |
| URL | https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/items — swap items for any collection on this page |
| Headers | Authorization → Bearer sto_live_… |
| Query string | limit=200, plus updated_since and any filter you need |
| Response | JSON. The rows are in data; follow next_cursor while has_more is true |
A schedule every 15 minutes, asking only for what changed, is the pattern that fits inside the rate limit comfortably and never re-processes the same row twice.
GET https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/items
?updated_since=2026-09-11T09:00:00Z
&include_deleted=true
&limit=200
Authorization: Bearer sto_live_...
updated_at you processed and feed it back in as updated_since next run.is_deleted: true are deletions — remove them on your side.has_more is true, loop with cursor before you finish the run.The version is in the path. Everything on this page is v1, and v1 is read-only.
error code does not — branch on that.Removing or renaming a field, changing a field’s type, changing what a filter means, or changing the response envelope. If any of that ever becomes necessary it ships as /v2, and /v1 keeps working.
Creating, updating and deleting records through the API is a planned future phase, not a current feature. Until then the apps are the only way to change anything, which is also why a leaked key cannot be used to wreck your inventory — only to read it. When write endpoints arrive they will be additive: new methods on the same paths, with the same key format and the same error vocabulary.
The user-facing side of this feature is covered in the Public API section of the documentation. For anything else — a limit that does not fit, a collection you need, a bug in a response — contact us and quote the request_id if you have one.
Mint a key in Settings → Developers → API Keys and start pulling. The API is included on every plan, and the 30-day trial is long enough to build the whole integration.