Sync your menu
Keep the ServeU menu identical to your POS. The key needs Update the menu (menu:write) and must be for all branches: creating, changing and deleting categories and items is business-wide, so a key limited to one branch gets 403 forbidden (it can still mark items sold out at its own branch).
Your codes are the link
- Give every category and item your own code as
external_id, e.g.CAT-HOTorPOS-1001. - Codes are 1–100 visible ASCII characters (no spaces) and case-sensitive:
POS-1001andpos-1001are two different items. A code is unique among the business's categories and among its items; a category and an item may share one. - Codes may contain
/,%,?or#: always put them in a path withencodeURIComponent.
Push with PUT, safe to repeat
PUT /api/v1/menu/categories/by-external-id/{code}first, thenPUT /api/v1/menu/items/by-external-id/{code}for each item (it needs the category's ServeUidascategory_id). Each creates the object the first time and updates it after that, so the whole sync is safe to run again.- An item PUT is a whole write. A field you leave out takes its default (
activetrue,reward_eligiblefalse, no description, allergens or tags). - A category PUT keeps the category's current
earns_stampsandactivewhen you leave them out (a new category starts with both true), so the owner's choices in the dashboard survive a sync that only sends names. Send them to change them. sizesandoption_groupsreplace the whole list: send them complete. Sizes, groups and options sent without anidare created new (with new ids); to keep one, send itsidfromGET /api/v1/menu.- Prices are in halalas (100 = 1.00 SAR). They include VAT when the business's
prices_include_vatis true (GET /api/v1/business). - Photos are managed in the dashboard only.
A sketch: read the menu first so each item keeps the ids of the sizes and options it already has, and build the Idempotency-Key from your POS's own version of the item, so a changed item is always applied again.
import { createHash } from "node:crypto";
const base = "https://serveu-psi.vercel.app/api/v1";
const auth = { Authorization: `Bearer ${process.env.SERVEU_API_KEY}`, "Content-Type": "application/json" };
// What ServeU has now, by your code: to send back the ids of sizes, groups and options.
const menu = await (await fetch(`${base}/menu`, { headers: auth })).json();
const current = new Map(menu.categories.flatMap((c) => c.items).filter((i) => i.external_id).map((i) => [i.external_id, i]));
const idByName = (list, en) => list?.find((x) => x.name.en === en)?.id; // undefined → created new
async function put(kind, code, version, body) {
const json = JSON.stringify(body);
const hash = createHash("sha256").update(json).digest("hex").slice(0, 12);
const res = await fetch(`${base}/menu/${kind}/by-external-id/${encodeURIComponent(code)}`, {
method: "PUT",
// Changes when the POS item (or what you send) changes, so a re-sync is applied again.
// Keep it ≤ 100 characters: shorten very long codes.
headers: { ...auth, "Idempotency-Key": `menu-${kind}-${code}-${version}-${hash}` },
body: json,
});
if (!res.ok) throw new Error(`${code}: ${(await res.json()).error.code}`);
return res.json();
}
for (const cat of pos.categories) {
const category = await put("categories", cat.code, cat.updatedAt, { name: { ar: cat.nameAr, en: cat.nameEn } });
for (const item of cat.items) {
const old = current.get(item.code);
await put("items", item.code, item.updatedAt, {
category_id: category.id,
name: { ar: item.nameAr, en: item.nameEn },
base_price: item.priceHalalas,
sizes: item.sizes.map((s) => ({
id: idByName(old?.sizes, s.nameEn),
name: { ar: s.nameAr, en: s.nameEn },
price: s.priceHalalas,
})),
option_groups: item.optionGroups.map((g) => {
const oldGroup = old?.option_groups.find((x) => x.name.en === g.nameEn);
return {
id: oldGroup?.id,
name: { ar: g.nameAr, en: g.nameEn },
required: g.required,
options: g.options.map((o) => ({
id: idByName(oldGroup?.options, o.nameEn),
name: { ar: o.nameAr, en: o.nameEn },
price_delta: o.extraHalalas,
})),
};
}),
});
}
}Changing one thing
PATCH /api/v1/menu/items/{id}changes only the fields you send. If the item changes in the dashboard at the same moment, your change is re-applied to the fresh item; if that keeps failing, the answer is409 conflict: read the item and try again.- Sold out:
POST /api/v1/menu/items/{id}/availabilitywith{"sold_out":true}(every branch) or{"sold_out":true,"branch_id":"…"}(one branch).idis ServeU's id from the PUT answer, not your code. - Gone from your POS:
DELETE /api/v1/menu/items/{id}. A category must be empty beforeDELETE /api/v1/menu/categories/{id}(else409 category_not_empty). Past orders keep their own copy of the item. - Stock counts (products you sell in limited numbers): see Sync stock.
- Read everything back with
GET /api/v1/menu: every category with its items, inactive ones included.
POST /api/v1/menu/items and POST /api/v1/menu/categories without an external_id are only safe to retry while the Idempotency-Key is remembered (24 hours, best-effort). For a sync, give everything an external_id and use PUT. See Idempotency.