Developers

Stockout API

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.

🔌

Overview

One bearer key, 24 JSON collections, everything your team has stocked.
v1Read-onlyIncluded on every plan

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.

Base URL

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>

Your first request

Request
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
     "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/items?limit=1&pretty=1"
Response
{
  "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
}

Good to know

  • A key belongs to exactly one team, and every query is scoped to that team on our servers. There is no parameter that lets you name a different team.
  • org_id is never returned — it would be the same value on every row. Your team’s id is in /v1/account.
  • A machine-readable description of everything on this page lives at openapi.json (OpenAPI 3.1). Import https://stockoutinventory.com/openapi.json into Postman, Insomnia or an SDK generator.
  • Times are ISO-8601 with an offset, e.g. 2026-09-11T09:14:02.117483+00:00. Money and quantities are JSON numbers, not strings.
🔑

Authentication

One bearer token per integration, minted in the app and shown exactly once.

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

Minting a key

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.

  1. Open Menu → Settings → Developers → API Keys in the Windows app, the Android app or the web app.
  2. Tap Create Key and give it a name that says where it will be used — “Zapier – reorder sheet” beats “key 2”.
  3. Copy the key from the dialog straight away. It is displayed once and is never recoverable; we only store a hash of it. Afterwards the list shows the first 13 characters (sto_live_9f3a) and when it was last used.
  4. Lost it? Mint a new one and revoke the old one. There is no “show key” button anywhere, by design.

Revoking

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.

What can go wrong

StatusMeaning
401 unauthorizedThe 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.

Tips

  • Authentication happens before the collection is resolved, so an unauthenticated caller gets 401 for every collection name — real and invented alike. You cannot probe what exists without a key.
  • Keys are not scoped to a location. One key reads every location the team has.
  • One key per integration. Then revoking one thing never breaks the others.
🛡️

Keeping keys safe

A key is a password to your whole team’s inventory. It belongs on a server.

Server-side only

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:

  • Keep it in an environment variable or your platform’s secret store — never hard-coded, never committed.
  • Give each integration its own key, so revoking one cannot break the rest.
  • Rotate by minting the new key first, switching the integration over, then revoking the old one. Revocation takes effect on the very next request.
  • Check Last used in the key list now and then. A key you do not recognise using should be revoked.
  • If a key ever leaks, revoke it immediately and mint a replacement. There is no way to “rotate the secret” on an existing key.

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.

🚫

What the API doesn’t return

A short list of things held back on purpose, and where to look instead.

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 returnedWhy, and what you get instead
Pending team invitesAn 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 themselvesThere 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 identifiersThe 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 identifiersThe 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/integrationssettings 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_versionWhich 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.

If you need something that is not here

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.

📡

Requests & responses

Two URL shapes, three response shapes, and a handful of headers.

Methods

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.”

URL shapes

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.

Response shapes

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." }

Response headers

HeaderNotes
Content-TypeAlways application/json.
Cache-Controlno-store. The body is specific to your team — do not let a shared cache near it.
VaryAuthorization, for the same reason.
X-Content-Type-Optionsnosniff.
X-RateLimit-LimitRequests allowed per minute for this key. See Rate limits.
X-RateLimit-RemainingHow many are left in the current minute.
X-RateLimit-ResetUnix epoch seconds at which the current window resets.
Retry-AfterSeconds 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.

Formatting

Responses are compact by default. Add ?pretty=1 for 2-space indentation while you are exploring; leave it off in production.

JSON held in text fields

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
📖

Pagination

Keyset cursors. Follow 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.

A worked example

Two rows at a time through /v1/folders, a small collection, so the rows fit whole.

Page 1 — request
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
     "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/folders?limit=2&pretty=1"
Page 1 — response
{
  "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
}
Page 2 — request
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" \
     "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/folders?limit=2&pretty=1&cursor=MjAyNi0wOS0xMVQxMTo0ODozMS45MDIwMDQrMDA6MDB8YzA3ZDVlMTItOWI4NC00ZjYwLTgzYWEtMTFlMmM2ZDQwYjk3"
Page 2 — response
{
  "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
}

Reading everything

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" });

Rules for cursors

  • The cursor is opaque. Do not build one, parse one, or edit one. Pass next_cursor back exactly as it arrived — anything else is 400 bad_cursor (“Pass back the next_cursor value exactly as returned.”).
  • It is already URL-safe base64, so it needs no extra encoding. If your HTTP client double-encodes query strings, turn that off for this parameter.
  • Never page with a timestamp of your own. The cursor carries the sort value and the row id together, because a restore, a sample-data reset or any bulk import writes hundreds of rows with a byte-identical updated_at. Slicing that group on the timestamp alone loses every row after the page boundary — silently.
  • A cursor is a position, not a snapshot. Rows edited while you are paging move to the end of the ordering and you will see them again; that is what makes the sync in Filtering safe to re-run.
  • 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.
🔎

Filtering

Five parameters every list takes, plus a short allow-list per collection.

On every list

ParameterValuesWhat it does
limit1–200, default 50How many rows per page. Anything else is 400 bad_request.
cursora next_cursorContinue from where the last page stopped. See Pagination.
updated_sinceISO-8601 timestampOnly 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_deletedtrue / false / 1 / 0Include 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.
pretty1Indent 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.

Per-collection filters

Each collection accepts only the filters listed below. They are all exact matches except sku.

FilterApplies toRules
location_id, item_id, kit_id, equipment_id, checklist_id, folder_id, supplier_id, work_order_id, customer_idthe collections listed under All collectionsExact match on a uuid. A value that is not a uuid is 400 bad_request (“<param> must be a uuid.”).
sku/v1/itemsCase-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-movementsExact match. 1–64 characters, letters, digits, ., - or _; anything else is 400 bad_request.
provider/v1/item-external-refsSame rule as source.
status/v1/work-ordersOne of draft, open, in_progress, on_hold, completed, cancelled. Anything else is 400 bad_request.

There is no wildcard, and * is not one

sku 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.

Unknown parameters are an error, not a shrug

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.

Keeping a copy in step

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().

🗑️

Deleted rows

Deletions are soft, so a mirror can learn that a row is gone instead of watching it vanish.

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.

RequestReturns
/v1/itemsLive items only.
/v1/items?include_deleted=trueLive 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=trueThe 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”.

Three collections have no deleted state

/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.”).

⚠️

Errors

Eleven machine codes. The 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."
}
StatusCodeWhen
400bad_requestA 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.
400bad_paramA 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.
400bad_cursorThe cursor was not produced by this API, or it has been re-encoded in transit. Pass next_cursor back byte-for-byte.
401unauthorizedMissing, malformed, unknown, revoked or expired key.
402no_active_subscriptionThe team has no active or trialing Stockout plan. Existing keys start working again the moment the plan is renewed.
404not_foundNo row with that id in your team’s data — or a path with more than two segments after /v1.
404unknown_collectionNo 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.
405method_not_allowedAnything other than GET, HEAD or OPTIONS. v1 is read-only.
429rate_limitedOver the per-key or per-team limit. See Rate limits.
500server_errorOur 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.
503service_unavailableThe 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.

A 500 never leaks

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

Handling them

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}`);
  }
}

Retry policy

  • Retry 429 after Retry-After, and 500 / 503 with exponential backoff.
  • Never retry a 400 — the request itself is wrong and will fail identically forever.
  • Never retry a 401 or 402 in a loop. Both need a person: a new key, or a renewed plan.
  • Requests are reads, so a retry can never duplicate anything.
⏱️

Rate limits

60 requests a minute per key, counted server-side in fixed windows.
BucketPer minutePer day
Per key6010,000
Per team (all keys together)12050,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."
}

Staying under

  • Use limit=200. One request for 200 rows costs the same as one request for 1.
  • Poll with updated_since instead of re-reading whole collections. A quiet minute then costs one request per collection.
  • Read X-RateLimit-Remaining and slow down before you hit zero, rather than after.
  • The X-RateLimit-* trio describes the per-key, per-minute bucket. A 429 whose hint mentions your team means another key burned the shared allowance.
  • The headers are advisory, not a guarantee: if the counter service is briefly unavailable we serve the request anyway and report the full per-minute allowance, rather than failing the call. The trio is always present, so a client must never read a missing header as “zero left”.
  • Needing more than this for an inventory feed usually means polling too often. If you genuinely do, get in touch.
🧭

Discovery & account

Two endpoints that describe the API and the team behind the key.

GET /v1

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.

Request
curl -s -H "Authorization: Bearer $STOCKOUT_API_KEY" "https://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1?pretty=1"
Response
{
  "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"
  }
}

GET /v1/account

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.

Response
{
  "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 }
  }
}
FieldNotes
orgYour 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.
planplan 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.
membersOne entry per teammate, oldest first: role (owner, admin, user or viewer), email and when they joined. Internal user ids are never included.
devicesRegistered devices, oldest first, with label, platform, last_seen_at and created_at. Device identifiers are never included.
locationsActive and inactive locations, by name, but not deleted ones. Handy for turning a name into the location_id other endpoints filter on.
keyThe 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_limitThe allowance for this key and how much of this minute is left.
📦

All collections

Twenty-four of them. Each takes 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.

CollectionPages onFilters
/v1/itemsupdated_atlocation_id, sku
/v1/locationsupdated_at
/v1/kitsupdated_atlocation_id
/v1/kit-componentsupdated_atkit_id, item_id
/v1/equipmentupdated_atlocation_id
/v1/equipment-logupdated_atequipment_id
/v1/checklistsupdated_atlocation_id
/v1/checklist-logupdated_atchecklist_id
/v1/foldersupdated_atlocation_id
/v1/folder-itemsupdated_atfolder_id
/v1/suppliersupdated_at
/v1/supplier-contactsupdated_atsupplier_id
/v1/item-suppliersupdated_atitem_id, supplier_id
/v1/customersupdated_at
/v1/work-ordersupdated_atlocation_id, customer_id, status
/v1/work-order-materialsupdated_atwork_order_id
/v1/work-order-equipmentupdated_atwork_order_id
/v1/work-order-checklistsupdated_atwork_order_id
/v1/work-order-laborupdated_atwork_order_id
/v1/work-order-activityupdated_atwork_order_id
/v1/label-templatesupdated_at
/v1/stock-movementsingested_atitem_id, location_id, source
/v1/item-external-refsupdated_atitem_id, provider
/v1/integrationsupdated_at

GET /v1/items

Everything you stock, at one location. This is the collection most integrations start with.

Pages on updated_at · Filters location_id, sku · Soft delete yes
FieldTypeNotes
iduuidPrimary key. Stable for the life of the item.
location_iduuid, nullableLocation 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.
namestringItem name. May be an empty string.
skustringOne or more codes joined with ", " — an item can carry several barcodes. The sku filter matches any substring of this whole field.
categorystring, nullableFree-text category.
quantitynumberQuantity on hand. Decimal, because weight/length/volume items are not whole numbers.
price_at_costnumberWhat you pay per unit.
price_at_retailnumberWhat you sell for per unit.
low_stock_thresholdnumberAt or below this the item reads Low. A percentage of max_capacity when low_stock_is_percent is true.
low_stock_is_percentbooleanWhether low_stock_threshold is a percentage rather than a count.
near_capacity_thresholdnumberAt or above this the item reads Near Capacity. A percentage of max_capacity when near_capacity_is_percent is true.
near_capacity_is_percentbooleanWhether near_capacity_threshold is a percentage rather than a count.
max_capacitynumberHow much the storage spot holds. 0 means capacity is not tracked.
descriptionstring, nullableFree-text notes.
storage_locationstring, nullableFree-text bin, shelf or aisle.
tracking_typestringQuantity, Weight, Length or Volume.
selected_unitstring, nullableUnit label (kg, ft, L…). Used when tracking_type is not Quantity.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/locations

The places you stock things. Most other collections carry a location_id pointing here.

Pages on updated_at · Filters none · Soft delete yes
FieldTypeNotes
iduuidPrimary key. Use it as the location_id filter elsewhere.
namestringLocation name.
addressstring, nullableStreet address.
citystring, nullableCity.
countrystring, nullableCountry.
is_activebooleanInactive locations stay in the data but are hidden in the apps.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/kits

Bundles built out of inventory items. Making or selling a kit moves the component items’ stock.

Pages on updated_at · Filters location_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
location_iduuid, nullableLocation the kit belongs to.
namestringKit name.
quantity_on_handnumberBuilt kits currently on hand.
descriptionstring, nullableFree-text notes.
storage_locationstring, nullableFree-text bin, shelf or aisle.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/kit-components

The recipe rows joining a kit to the items it consumes.

Pages on updated_at · Filters kit_id, item_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
kit_iduuidThe kit. Matches /v1/kits id.
item_iduuidThe component item. Matches /v1/items id.
quantity_needednumberHow many of the item one kit consumes.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/equipment

Reusable gear that gets checked out and back in rather than consumed.

Pages on updated_at · Filters location_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
location_iduuid, nullableLocation the gear lives at.
namestringEquipment name.
total_quantityintegerHow many units you own. What is available is derived from the check-out log, not stored here.
descriptionstring, nullableFree-text notes.
storage_locationstring, nullableFree-text bin, shelf or aisle.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/equipment-log

One row per check-out or check-in. Subtract the check-ins from the check-outs to get what is still out.

Pages on updated_at · Filters equipment_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
equipment_iduuidThe equipment. Matches /v1/equipment id.
employee_firststringFirst name typed at the time.
employee_laststringLast name typed at the time.
is_check_outbooleantrue = taken out, false = brought back.
quantityintegerHow many units this row moves.
occurred_attimestampWhen it happened, supplied by the app. It can be backdated, so it is never the sort column — page and filter on updated_at.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/checklists

Reusable checklists your team runs and ticks off.

Pages on updated_at · Filters location_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
location_iduuid, nullableLocation the checklist belongs to.
namestringChecklist name.
descriptionstring, nullableFree-text notes.
items_jsonJSON stringThe checklist’s steps, as a JSON string — not an object. See JSON held in text fields.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/checklist-log

One row per completed run of a checklist.

Pages on updated_at · Filters checklist_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
checklist_iduuidThe checklist. Matches /v1/checklists id.
employee_firststringFirst name typed at the time.
employee_laststringLast name typed at the time.
started_attimestampWhen the run began, supplied by the app. Never the sort column.
completed_attimestampWhen Complete was tapped, supplied by the app. Never the sort column.
completed_countintegerHow many steps were ticked.
total_countintegerHow many steps the checklist had at the time.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/folders

Named collections that reference other records. A folder holds pointers, never copies.

Pages on updated_at · Filters location_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
location_iduuid, nullableLocation the folder belongs to.
namestringFolder name.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/folder-items

The pointers inside a folder. Each row names a kind and an id in the matching collection.

Pages on updated_at · Filters folder_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
folder_iduuidThe folder. Matches /v1/folders id.
entity_typestringitem, kit, equipment, checklist, label_template, supplier, work_order or customer.
entity_iduuidThe referenced row’s id, in the collection entity_type names.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/suppliers

Your vendor directory. Suppliers are team-wide, not tied to a location.

Pages on updated_at · Filters none · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
namestringSupplier name.
portal_urlsstring, nullableOrdering-portal links, one per line or comma-separated.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/supplier-contacts

People at a supplier.

Pages on updated_at · Filters supplier_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
supplier_iduuidThe supplier. Matches /v1/suppliers id.
namestring, nullableContact name.
phonestring, nullablePhone number, as typed.
emailstring, nullableEmail address, as typed.
rolestring, nullableJob title or role.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/item-suppliers

The join table saying which suppliers can provide which item.

Pages on updated_at · Filters item_id, supplier_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
item_iduuidMatches /v1/items id.
supplier_iduuidMatches /v1/suppliers id.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/customers

Who work orders are done for. Customers are team-wide, not tied to a location.

Pages on updated_at · Filters none · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
namestringCompany or customer name.
contact_namestring, nullableMain contact person.
phonestring, nullablePhone number, as typed.
emailstring, nullableEmail address, as typed.
addressstring, nullableAddress, as typed.
notesstring, nullableFree-text notes.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-orders

Jobs with materials, labour, equipment and checklists hanging off them.

Pages on updated_at · Filters location_id, customer_id, status · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
location_iduuid, nullableLocation the job belongs to.
numberintegerPer-team sequence number, shown in the apps as WO-<number>.
titlestring, nullableShort job title.
customer_iduuid, nullableMatches /v1/customers id. Null for an internal job.
statusstringdraft, open, in_progress, on_hold, completed or cancelled.
prioritystringlow, normal, high or urgent.
assigned_tostring, nullableFree-text name of whoever owns the job.
scheduled_datetimestamp, nullableWhen the job is booked in.
due_datetimestamp, nullableWhen the job is due.
completed_attimestamp, nullableWhen the job was completed, supplied by the app. Never the sort column.
amount_chargednumber, nullableWhat the customer is billed. Feeds the margin in the cost summary.
notesstring, nullableFree-text notes.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-order-materials

Items and kits planned for or consumed by a job. Using or fulfilling materials moves real stock.

Pages on updated_at · Filters work_order_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
work_order_iduuidMatches /v1/work-orders id.
entity_typestringitem or kit.
entity_iduuidThe referenced row’s id, in the collection entity_type names.
qty_plannednumberHow much the job expects to use.
qty_usednumberHow much has actually been pulled from stock.
unit_costnumberCost per unit, snapshotted when the line was added.
unit_pricenumberRetail per unit, snapshotted when the line was added.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-order-equipment

Gear reserved for a job.

Pages on updated_at · Filters work_order_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
work_order_iduuidMatches /v1/work-orders id.
equipment_iduuidMatches /v1/equipment id.
quantityintegerHow many units are reserved.
checked_outbooleanWhether the gear has actually left the shelf for this job.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-order-checklists

Checklists attached to a job.

Pages on updated_at · Filters work_order_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
work_order_iduuidMatches /v1/work-orders id.
checklist_iduuidMatches /v1/checklists id.
completedbooleanWhether this checklist has been finished for this job.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-order-labor

Time booked against a job. hours × rate is what job costing adds up.

Pages on updated_at · Filters work_order_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
work_order_iduuidMatches /v1/work-orders id.
employeestring, nullableFree-text name.
hoursnumberHours booked.
ratenumberHourly rate used for costing.
notesstring, nullableFree-text notes.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/work-order-activity

The audit trail on a job.

Pages on updated_at · Filters work_order_id · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
work_order_iduuidMatches /v1/work-orders id.
kindstring, nullableWhat kind of event this was, as free text.
detailstring, nullableFree-text detail.
occurred_attimestampWhen the event happened, supplied by the app. Never the sort column.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/label-templates

Saved label designs. Templates are team-wide — they are shared across every location.

Pages on updated_at · Filters none · Soft delete yes
FieldTypeNotes
iduuidPrimary key.
namestringTemplate name.
width_innumberLabel width in inches.
height_innumberLabel height in inches.
elements_jsonJSON stringThe template’s fields, as a JSON string — not an object. See JSON held in text fields.
updated_attimestampLast time any field changed. Sort column for this collection.
is_deletedbooleantrue once the record has been deleted in the app. See Deleted rows.

GET /v1/stock-movements

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.

Pages on ingested_at · Filters item_id, location_id, source · Soft delete no — rows are never flagged deleted
FieldTypeNotes
iduuidPrimary key.
item_iduuidMatches /v1/items id.
location_iduuid, nullableWhere the movement happened.
deltanumberSigned change in quantity. Negative means stock left.
reasonstring, nullableFree-text reason written by the app.
sourcestring, nullableStructured channel tag, e.g. an integration name. Null for movements made in the apps.
created_attimestampWhen 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_attimestampWhen 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.

GET /v1/item-external-refs

Maps a Stockout item to the same product in a connected system.

Pages on updated_at · Filters item_id, provider · Soft delete no — rows are never flagged deleted
FieldTypeNotes
iduuidPrimary key.
item_iduuidMatches /v1/items id.
providerstringWhich system the reference belongs to.
external_idstringThat system’s product or variant id.
skustring, nullableThat system’s SKU, when it has one.
updated_attimestampLast time any field changed. Sort column for this collection.

GET /v1/integrations

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.

Pages on updated_at · Filters none · Soft delete no — rows are never flagged deleted
FieldTypeNotes
iduuidPrimary key.
providerstringWhich system is connected.
statusstringconnected, error or disconnected.
external_idstring, nullableThe provider’s shop or account id.
last_sync_attimestamp, nullableLast successful sync, or null if it has never run.
created_attimestampWhen the record was first created.
updated_attimestampLast time any field changed. Sort column for this collection.
🔗

Zapier, Make & n8n

There is no Stockout app in those marketplaces yet — their generic HTTP steps work today.

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.

PlatformUse this step
ZapierWebhooks by ZapierGET, or a Code step for anything that has to follow cursors.
MakeThe HTTP module → Make a request.
n8nThe HTTP Request node. Its Predefined Credential Type is not needed — add the header yourself.

Settings to enter

FieldValue
MethodGET
URLhttps://puhxxxsdvurwavbmuhhd.supabase.co/functions/v1/api/v1/items — swap items for any collection on this page
HeadersAuthorizationBearer sto_live_…
Query stringlimit=200, plus updated_since and any filter you need
ResponseJSON. The rows are in data; follow next_cursor while has_more is true

A polling scenario

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_...
  1. Store the newest updated_at you processed and feed it back in as updated_since next run.
  2. Rows come back oldest first, so the last row of the last page is your new watermark.
  3. Rows with is_deleted: true are deletions — remove them on your side.
  4. If has_more is true, loop with cursor before you finish the run.

Before you build

  • Most no-code platforms store the key in plain sight in the scenario. Keep those scenarios private, and mint a key used by nothing else so it can be revoked on its own.
  • The API is read-only, so these integrations can report, alert and copy — but they cannot write stock back into Stockout. That is a later phase.
  • Importing openapi.json into Postman or Insomnia gives you every endpoint pre-filled while you work out the request you want.
📌

Versioning & what’s next

What we can change without warning, and what we cannot.

The version is in the path. Everything on this page is v1, and v1 is read-only.

Changes you should expect

  • New fields on existing rows and new collections can appear at any time. Ignore fields you do not recognise rather than failing on them.
  • Hint wording changes freely. The error code does not — branch on that.
  • Cursor encoding is an implementation detail and may change. It is opaque for exactly this reason; a cursor is only ever valid for the request that follows the one that produced it.

Changes that would need a v2

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.

Writing data

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.

Questions?

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.

Build it on your own data

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.