Sync stock

Sell physical products (coffee beans, mugs, merch) as ordinary menu items, collected by pickup. Your system keeps the stock; ServeU receives the count per item and branch, takes it off when customers order, shows "Only 3 left" and "Sold out", and tells your system what sold.

The key

  • Writing stock needs Update stock (stock:write). Reading it back (GET /api/v1/menu, GET /api/v1/menu/items/{id}) needs menu:read. menu:write does not include stock:write.
  • Stock is per item and branch, so every write — the two PUTs and each row of the batch — names a branch_id. ServeU's ids (item_id, branch_id) may be sent in upper or lower case; your own external_id codes stay case-sensitive, as in Sync your menu.
  • A key limited to one branch may set stock only at its own branch; any other branch answers 404 not_found.
  • A PUT answers { item_id, external_id, branches: [{ branch_id, stock, stock_updated_at }, …] }: every branch of the business, or only the key's own branch when the key is limited to one.
  • All three writes accept Idempotency-Key; a replay returns the stored result. See Idempotency.

Your codes are the link

Give each product your own code as external_id (see Sync your menu). Then you never need ServeU's ids: PUT /api/v1/menu/items/by-external-id/{code}/stock sets one item, and POST /api/v1/stock sets up to 100 in one call.

Full sync on a schedule

Every few minutes (and after a stock take), send every tracked product. The whole call counts as one request for the rate limit. Each row succeeds or fails on its own; a malformed row (or 0 or more than 100 rows) makes the whole request 400 invalid_request before anything is written. Otherwise an unknown item, or (for a key bound to one branch) a different branch, is not_found for that row only; a branch that belongs to another business is invalid_request for that row. The same item and branch twice in one call — the later row wins.

// OLAYA is your branch's ServeU id; runId is your own id for this sync run.
const items = [
  { external_id: "BEANS-1KG", branch_id: OLAYA, quantity: 40 },
  { external_id: "MUG-WHITE", branch_id: OLAYA, quantity: 0 },
];
const res = await fetch("https://serveu-psi.vercel.app/api/v1/stock", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.SERVEU_KEY}`,
    "content-type": "application/json",
    "idempotency-key": `stock-${runId}`, // a retry of the same run answers the stored result
  },
  body: JSON.stringify({ items }),
});
if (!res.ok) throw new Error((await res.json()).error.code); // a malformed request, before any row ran
const { results } = await res.json(); // [{ index: 0, ok: true }, { index: 1, ok: true }]
for (const r of results) if (!r.ok) console.warn(items[r.index].external_id, r.error.code);

How ServeU counts

  • Last write wins. The number you send replaces ServeU's count, including what orders took off since your last update. stock_updated_at is when your system last set it.
  • When a customer places an order, ServeU takes the quantity off (lines of the same item in different sizes add up). If any item has fewer left than ordered, the whole order is refused and the customer sees which item is short (insufficient stock); nothing is taken. Unpaid online orders hold their stock until they are paid or expire.
  • A rejected or cancelled order, or an online order that expires unpaid, puts its stock back, once. If your system already sent a new count after that order, the return is added on top of it: send a fresh count after you handle a reject (order.status_changed with status: rejected) — or, for an order that expired unpaid, after the stock.changed with reason: return, since an expired order never gets an order event at all.
  • One exception: when staff reject an order because the item isn't there (reject_reason: item_unavailable on the order.status_changed event), ServeU puts nothing back for any item in that order and sends no stock.changed for it. The count stays where the order left it until your system sends the true number, so send a fresh count when you see that reject. Nothing puts that order's stock back later either.
  • A paid order is never refused for stock afterwards.
  • quantity: 0 shows the item as sold out at that branch. quantity: null stops tracking: the item can be ordered without a limit again (the owner can also press Stop tracking stock in the dashboard; your next count starts it again).
  • Staff can still mark a tracked item sold out on the iPad (a damaged bag, say); that is separate from the count.
  • Delivery isn't supported yet: customers collect by pickup (or in the shop).

Hear what sold

  • order.created and order.status_changed stay the main signal: they carry the items and quantities of each order.
  • stock.changed comes with every move of ServeU's count: data.object has item_id, your external_id, branch_id, previous, current and reason (order, return, api or untracked), plus order_id for order and return.
  • stock.depleted follows when the count reaches 0.
  • For an order, ServeU writes stock.changed (and stock.depleted) before order.created; for a rejected order's return, it's queued before order.status_changed. Don't assume the order event always arrives first.
  • An unpaid online order takes stock while it's still awaiting_payment — before you get order.created for it, which ServeU sends only once the order is paid. If it expires unpaid instead, there is no order.created or order.status_changed for it at all — only the two stock.changed events (reason: order then reason: return). So order_id on a stock.changed event can name an order your system hasn't received yet, or never will if it expires unpaid. Key your own records on the order id and accept either order, whichever arrives first.
See Webhooks for signatures, retries and an example of each event.

Next: Send orders to a kitchen screen →