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-HOT or POS-1001.
  • Codes are 1–100 visible ASCII characters (no spaces) and case-sensitive: POS-1001 and pos-1001 are 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 with encodeURIComponent.

Push with PUT, safe to repeat

  • PUT /api/v1/menu/categories/by-external-id/{code} first, then PUT /api/v1/menu/items/by-external-id/{code} for each item (it needs the category's ServeU id as category_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 (active true, reward_eligible false, no description, allergens or tags).
  • A category PUT keeps the category's current earns_stamps and active when 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.
  • sizes and option_groups replace the whole list: send them complete. Sizes, groups and options sent without an id are created new (with new ids); to keep one, send its id from GET /api/v1/menu.
  • Prices are in halalas (100 = 1.00 SAR). They include VAT when the business's prices_include_vat is 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 is 409 conflict: read the item and try again.
  • Sold out: POST /api/v1/menu/items/{id}/availability with {"sold_out":true} (every branch) or {"sold_out":true,"branch_id":"…"} (one branch). id is 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 before DELETE /api/v1/menu/categories/{id} (else 409 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.

Next: Sync stock →