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}) needsmenu:read.menu:writedoes not includestock: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 ownexternal_idcodes 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_atis 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_changedwithstatus: rejected) — or, for an order that expired unpaid, after thestock.changedwithreason: 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_unavailableon theorder.status_changedevent), ServeU puts nothing back for any item in that order and sends nostock.changedfor 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: 0shows the item as sold out at that branch.quantity: nullstops 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.createdandorder.status_changedstay the main signal: they carry the items and quantities of each order.stock.changedcomes with every move of ServeU's count:data.objecthasitem_id, yourexternal_id,branch_id,previous,currentandreason(order,return,apioruntracked), plusorder_idfor order and return.stock.depletedfollows when the count reaches 0.- For an order, ServeU writes
stock.changed(andstock.depleted) beforeorder.created; for a rejected order's return, it's queued beforeorder.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 getorder.createdfor it, which ServeU sends only once the order is paid. If it expires unpaid instead, there is noorder.createdororder.status_changedfor it at all — only the twostock.changedevents (reason: orderthenreason: return). Soorder_idon astock.changedevent 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.