ala.menu for developers

Connect your POS to ala.menu

ala.menu hosts the digital menu your customers open on their phone. This API keeps that menu in step with the system you already run. Your POS or back office stays the source of truth for prices and stock. You push what changed to one endpoint, and the menu updates within seconds. You cannot delete anything and you cannot break how the menu looks: the API only accepts facts about items, and everything about presentation is managed on the ala.menu side.

v1https://ala.menu/api/pos/v1Download openapi.yaml

The model

How it works

  1. 1

    You get a key

    We issue one secret key per menu. Send it on every request as Authorization: Bearer pos_YOURMENU_xxxx. Keep it on your server. If it leaks, tell us and we issue a new one.

  2. 2

    You send items by their own id

    Every item carries an externalId: a barcode, or any string from your system that identifies the item and never changes. One externalId is one item on the menu. You never need to know our item ids.

  3. 3

    You send only what changed, whenever it changes

    A price change is one small call with one item and one field. Fields you leave out are left alone. Items you leave out are left alone. Wire this to whatever event your system already fires.

  4. 4

    On a schedule you choose, you send everything

    One call with your entire active catalog. It is the safety net. Anything a delta missed, dropped or got wrong corrects itself at the next full sync. The gap between full syncs is the longest a missed change can stay wrong, so pick the window you can live with. Most integrations run it daily, and a quiet hour works well.

Ten minutes

Quickstart

Three calls. Replace pos_YOURMENU_xxxx with the key we gave you and paste each block into a terminal.

1. Check the key

request
curl https://ala.menu/api/pos/v1/ping \
  -H "Authorization: Bearer pos_YOURMENU_xxxx"
response 200
{
  "ok": true,
  "menu": "Pegasus Pet Center",
  "currency": "USD"
}

If the menu name and the currency are the ones you expect, the key is in the right environment.

2. See what we already hold

request
curl https://ala.menu/api/pos/v1/catalog \
  -H "Authorization: Bearer pos_YOURMENU_xxxx"
response 200
{
  "ok": true,
  "currency": "USD",
  "items": [
    {
      "externalId": "9003579311776",
      "name": "RC Wet Sterilised Jelly 85g",
      "price": 4.5,
      "inStock": true,
      "hidden": false,
      "hasImage": true,
      "brand": "Royal Canin",
      "size": "85g",
      "barcode": "9003579311776"
    },
    {
      "externalId": "9003579311714",
      "name": "RC Kitten Dry 2kg",
      "price": 28,
      "inStock": false,
      "hidden": false,
      "hasImage": false,
      "brand": "Royal Canin",
      "size": "2kg"
    }
  ]
}

This is the menu as customers see it, listed by your own ids. Use it to check that your ids match ours before you write anything.

3. Send your first change

One price change and one item going out of stock, in one call.

request
curl -X POST https://ala.menu/api/pos/v1/sync \
  -H "Authorization: Bearer pos_YOURMENU_xxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "mode": "delta",
    "currency": "USD",
    "items": [
      { "externalId": "9003579311776", "price": 5.0 },
      { "externalId": "9003579311714", "quantity": 0 }
    ]
  }'
response 200
{
  "ok": true,
  "received": 2,
  "updated": 2,
  "created": 0,
  "staged": 0,
  "unchanged": 0,
  "hidden": 0,
  "unhidden": 0,
  "rejected": []
}

Open the menu and the new price is there. Run the same call again and the response comes back with unchanged: 2, because nothing moved the second time.

Reference

Endpoints

Base URL https://ala.menu/api/pos/v1. Every endpoint needs the same header: Authorization: Bearer pos_YOURMENU_xxxx. Responses are JSON.

Endpoint

Check the key#

GET/pingAuth: Bearer key

Confirms your key works and tells you which menu it is tied to.

Takes
No parameters. No request body.
Returns
ok, menu and currency. HTTP 200.
ok
boolean
no
Always true on success.
menu
string
yes
The menu name. Null if the menu has no name set.
currency
string
no
The ISO code every payload you send must match.
response 200
{
  "ok": true,
  "menu": "Pegasus Pet Center",
  "currency": "USD"
}

Errors: the shared ones below. There is nothing else this call can get wrong.

Endpoint

Read what we hold#

GET/catalogAuth: Bearer key

Returns every item on the menu that is linked to one of your external ids, in the state customers see it.

Takes
No parameters. No request body.
Returns
currency and an items array, one row per linked item. HTTP 200.
currency
string
no
The menu currency.
items[].externalId
string
no
Your own id for the item.
items[].name
string
yes
The name on the menu. If the owner edited it by hand, that edit is what you get back, not the name you sent.
items[].price
number
yes
The price on the menu, with the same rule as the name.
items[].inStock
boolean
no
False when the item is showing as sold out.
items[].hidden
boolean
no
True when the item is off the menu: marked idle, held for review, or missing from your last full snapshot. It still exists and can come back.
items[].hasImage
boolean
no
True when the item has a photo, whoever supplied it.
items[].brand
string
no
The brand on the menu, same owner-edit rule as the name. Left out of the row entirely when the item has none.
items[].size
string
no
The pack size on the menu. Left out when the item has none.
items[].barcode
string
no
The barcode on the menu. Left out when the item has none. Useful as a join key when your own externalId is not the barcode.
response 200
{
  "ok": true,
  "currency": "USD",
  "items": [
    {
      "externalId": "9003579311776",
      "name": "RC Wet Sterilised Jelly 85g",
      "price": 4.5,
      "inStock": true,
      "hidden": false,
      "hasImage": true,
      "brand": "Royal Canin",
      "size": "85g",
      "barcode": "9003579311776"
    },
    {
      "externalId": "9003579311714",
      "name": "RC Kitten Dry 2kg",
      "price": 28,
      "inStock": false,
      "hidden": false,
      "hasImage": false,
      "brand": "Royal Canin",
      "size": "2kg"
    }
  ]
}

One thing to note. Items the menu owner created by hand are not in this list, because they are not linked to your system.

The retail facets come back here too, so anything you push you can read back and check against your own records. When your externalId is not the barcode, barcode is the column to match your rows on.

Errors: the shared ones below.

Endpoint

Push changes#

POST/syncAuth: Bearer key

Sends a batch of items. This is the endpoint the whole integration hangs off, and it has two modes.

Takes
A JSON body: mode, currency and items (up to 500 per delta call, up to 25,000 in a full sync).
Returns
Eight fields saying what landed, down to the zeros. HTTP 200, or HTTP 422 when the whole payload is refused.

Request fields

mode
"delta" | "full"
no
Defaults to delta when you leave it out.
currency
string
yes
Three letters, like USD. It must match the menu currency or the whole call is rejected. This is what stops a price list in the wrong currency from landing.
items
array
yes
The items to apply. Up to 500 per delta call. A full sync carries the entire catalog in ONE call, up to 25,000 items, because absence from a full sync means discontinued. An empty array is an error, not a no-op.

Item fields

Only externalId is required. Everything else is optional, and leaving a field out means "do not change this".

externalId
string
yes
Your id for the item. Up to 128 characters. It cannot contain a slash or control characters, cannot start with two underscores, and must contain at least one letter or digit.
name
string
no
One to 200 characters, trimmed. Never null. Required the first time you send an unknown externalId.
price
number
no
Zero or above, in the menu currency. Never null. Required the first time you send an unknown externalId.
quantity
number
no
Your real stock count, zero or above. Zero marks the item sold out. Anything above zero brings it back. Leave it out if you do not track stock.
status
"active" | "idle"
no
idle takes the item off the menu without deleting it. active puts it back, whatever the reason it was hidden.
category
object | null
no
{ "id": "17", "name": "Dog Food" }, using your own category id. An id we have not seen creates a new menu section. Send null to clear the category and drop the item into a fallback section.
imageUrl
string | null
no
An http or https link to the item photo, up to 2000 characters, if you host images yourself. Send null to clear the photo we hold. To upload a file instead, use the image endpoint below.
brand
string | null
no
The product brand, up to 200 characters, trimmed. Shown on the product itself. Send null to clear it.
size
string | null
no
The pack size as the label reads it, like 500g or 6 x 1L. Up to 200 characters, trimmed. Send null to clear it.
barcode
string | null
no
The item barcode, up to 64 characters, trimmed. Worth sending when the barcode is not already your externalId. Send null to clear it.

mode: "delta", the everyday call

When to use it: every time something changes in your system. A price edit, a stock movement, a new product, a discontinued line. This is the normal path and it is what makes the menu update within seconds.

A delta carries only the items that changed, and inside each item only the fields that changed. Items you do not mention are untouched.

request body
{
  "mode": "delta",
  "currency": "USD",
  "items": [
    { "externalId": "9003579311776", "price": 5.0 },
    { "externalId": "9003579311714", "quantity": 0 },
    { "externalId": "9003579311888", "status": "idle" },
    {
      "externalId": "6191544800014",
      "name": "New Product",
      "price": 12.0,
      "quantity": 20,
      "category": { "id": "17", "name": "Dog Food" },
      "brand": "Royal Canin",
      "size": "2kg"
    }
  ]
}
response 200
{
  "ok": true,
  "received": 4,
  "updated": 3,
  "created": 1,
  "staged": 0,
  "unchanged": 0,
  "hidden": 1,
  "unhidden": 0,
  "rejected": []
}

Three existing items moved (a price, a stock count, one going idle) and one unknown id created a new item. The idle one counts in both updated and hidden, because it changed and it left the menu in the same call.

mode: "full", the full sync

When to use it: on a schedule you choose, carrying your entire active catalog. How often is your call: the gap between full syncs is the longest anything a delta missed can stay wrong. Most integrations run it daily. It is a safety net, not a way to make routine updates. Do not use it to push a single price change.

Same payload shape. We reconcile everything against it: values that drifted are corrected, and linked items that are not in the snapshot are hidden. Nothing is deleted, and an item that shows up again in a later snapshot comes back on its own.

request body
{
  "mode": "full",
  "currency": "USD",
  "items": [
    {
      "externalId": "9003579311776",
      "name": "RC Wet Sterilised Jelly 85g",
      "price": 5.0,
      "quantity": 42,
      "category": { "id": "17", "name": "Dog Food" }
    },
    {
      "externalId": "9003579311714",
      "name": "RC Kitten Dry 2kg",
      "price": 28.0,
      "quantity": 0,
      "category": { "id": "18", "name": "Cat Food" }
    }
  ]
}

A full snapshot that leaves out too much of the catalog is refused. See suspect_snapshot in the errors below.

Response counters

The response tells you exactly what happened. Every call returns all eight fields, including the zeros.

fields
received
How many rows were in your items array, rejected ones included.
updated
Existing items where something actually changed.
created
New items created and live on the menu.
staged
New items created but deliberately held off the menu. That happens when the owner asked for a review step on new items, when the owner asked to hold items that arrive with no photo, or when you created the item with status: "idle". They exist and wait to be released.
unchanged
Existing items whose values already matched what you sent.
hidden
Items that went from visible to hidden on this call: you marked them idle, or a full snapshot left them out.
unhidden
Items that came back on this call: you sent status: "active", or a full snapshot included an item that an earlier snapshot had missed.
rejected
An array of { externalId, reason }. Rows we could not apply. The rest of the batch still landed. Usually empty.

created, updated, staged, unchanged and the length of rejected add up to received. hidden and unhidden count something else: they overlap with updated, and a full snapshot can hide items you did not mention at all.

Two kinds of failure

Some problems are about one row, and some are about the whole payload. They are answered differently on purpose.

One row is wrong. You get HTTP 200. The rest of the batch lands and the bad rows come back in rejected. A single malformed product must never strand a full sync.

response 200
{
  "ok": true,
  "received": 3,
  "updated": 2,
  "created": 0,
  "staged": 0,
  "unchanged": 0,
  "hidden": 0,
  "unhidden": 0,
  "rejected": [
    { "externalId": "6191544800014", "reason": "missing_price" }
  ]
}

The payload is wrong. You get HTTP 422 and nothing at all is written. Accepting half of a payload with the wrong currency in it would be worse than accepting none of it.

response 422
{ "ok": false, "error": "currency_mismatch" }

Whole-call errors, HTTP 422

Nothing was written. Fix the payload and resend.

error
invalid_body
The body was not valid JSON, or not a JSON object.
invalid_mode
mode was something other than delta or full.
invalid_currency
Currency missing, or not three letters.
currency_mismatch
Your currency is not the menu currency. Call /ping to see which one the menu uses.
invalid_items
items was not an array.
empty_items
items was an empty array. Send at least one item.
too_many_items
Over the cap: 500 items in a delta call, 25,000 in a full sync. Split delta batches; never split a full sync.
duplicate_external_id
The same externalId appeared twice in one payload. Which row wins would be a coin toss, so neither does. Merge them and resend.
suspect_snapshot
A full snapshot left out too much of the catalog, which reads as a broken export rather than a real mass discontinuation. It triggers when the menu holds more than 20 visible linked items and more than 10 percent of them are missing from the snapshot. Nothing was changed. Check the export and send it again.
category_doc_too_large
One menu section grew past the storage limit. Split that category in your system so the items land in smaller sections.

Per-item reasons, inside rejected

These come back with HTTP 200. The rest of the batch landed.

reason
invalid_item
The row was not a JSON object.
invalid_external_id
Missing, empty, over 128 characters, or containing a character we cannot store. The rejection comes back with an empty externalId, since there was no usable one.
invalid_null
You sent name or price as null. Those two can never be cleared. To say nothing about a field, leave it out.
invalid_name
Not a string, empty after trimming, or over 200 characters.
invalid_price
Not a finite number, or below zero.
invalid_quantity
Not a finite number, or below zero.
invalid_status
Not active or idle.
invalid_category
The category object had no usable id, or a name that is not a usable string.
invalid_image
The image link was not an http or https URL, or was over 2000 characters.
invalid_brand
Not a string, empty after trimming, or over 200 characters.
invalid_size
Not a string, empty after trimming, or over 200 characters.
invalid_barcode
Not a string, empty after trimming, or over 64 characters. Send it as a string, even when it is all digits.
missing_name
An unknown externalId arrived without a name. Creating an item needs a name and a price.
missing_price
An unknown externalId arrived without a price. Same rule as above.
item_not_found
An internal safety net. You should never see it. If you do, resend the item and tell us.

Endpoint

Upload an item photo#

POST/items/{externalId}/imageAuth: Bearer key

Sends one photo for one item. The image file is the raw request body, not a form field and not JSON.

Takes
The externalId in the path, an image Content-Type, and the file itself as the body.
Returns
imageUrl and applied. HTTP 200.
externalId
string, in the path
yes
The same id you send to /sync. The item has to exist already, so create it with a sync call first.
Content-Type
header
yes
image/jpeg or image/png. We also read the first bytes of the file, so a header that disagrees with the file is refused.
body
binary
yes
The raw file, up to 5 MB.
request
curl -X POST https://ala.menu/api/pos/v1/items/9003579311776/image \
  -H "Authorization: Bearer pos_YOURMENU_xxxx" \
  -H "Content-Type: image/jpeg" \
  --data-binary @product.jpg
response 200
{
  "ok": true,
  "imageUrl": "https://firebasestorage.googleapis.com/...jpg?alt=media&token=...",
  "applied": true
}

We resize the photo to fit within 1600 by 1600 and store it as a JPEG at its own permanent URL. Nothing is ever overwritten, so a new upload is always a new URL.

applied tells you whether your photo is the one customers now see. It comes back false when the menu owner has set their own photo for that item: yours is stored, theirs keeps showing. That is not an error and there is nothing to retry.

errors
404 unknown_external_id
No item on this menu carries that id. Sync it first.
422 invalid_external_id
The id was missing from the path.
422 empty_body
The request body had no bytes.
422 image_too_large
Over 5 MB.
422 unsupported_image_type
The file is not a JPEG or a PNG, whatever the header said.
422 unreadable_image
The bytes are a JPEG or PNG we could not decode.
422 category_doc_too_large
The menu section this item sits in is over the storage limit.

Shared

Errors every endpoint can return#

Errors always come back as { "ok": false, "error": "..." }. The error string is stable, so it is safe to log it and to branch on it.

status and error
401 unauthorized
The key is missing, malformed, or not one of ours. All three answer the same way on purpose, so a wrong key tells a stranger nothing.
403 integration_inactive
The key is valid but the POS integration is switched off for this menu. Ask the owner to turn it back on.
404 menu_not_found
The menu this key belongs to no longer exists.
429 rate_limited
Over 120 requests a minute for this menu, counted across all four endpoints. Wait, then retry. If you hit this often, put more items in each call.
500 internal_error
Something failed on our side. Nothing half-applied was written. Retry.

Guarantees

Rules that keep you safe

These are the reasons you can wire this up without a rollback plan. None of them depend on your code being careful.

  • A field you leave out changes nothing

    Absent means "no change", never "clear this". Sending only externalId and price touches the price and nothing else.

  • An explicit null clears the field

    Only category, imageUrl, brand, size and barcode can be cleared that way. A null name or price is a bug in the caller, not an instruction, so that row is rejected and the item keeps what it had.

  • Resending is always harmless

    Every call is idempotent. If a request times out and you do not know whether it landed, send it again. A repeat that changes nothing counts as unchanged and does not even touch the menu.

  • Nothing is ever deleted

    There is no delete in this API. An item you stop sending, or mark idle, is hidden. Its history, its photos and the owner edits on it all stay. Include it in a full sync again and it comes back on its own.

  • A broken export cannot wipe the menu

    A full snapshot missing a large share of the catalog is refused as suspect_snapshot and changes nothing. A half-written export file cannot empty a menu.

  • Quantity zero sells out, above zero brings back

    Stock is a fact you own. Zero shows the item as sold out and blocks it at checkout, anything above zero restores it. If you never send quantity, we leave the sold-out switch the owner set by hand exactly as it is.

  • Presentation is not yours to break

    Layout, descriptions, section order and design are managed on the menu side. Your calls cannot reorder, rename or remove anything you were not talking about. If an owner has edited a name, a price or a photo by hand, that edit stays on top and your value is kept underneath.

Questions

FAQ

What if the internet at the shop dies?

Queue the changes and send them when the connection is back. Resending is safe, and order does not matter much because the last value wins. If you would rather not build a queue, do nothing: your next full sync fixes everything the outage lost.

What if I send the same thing twice?

Nothing happens the second time. The response comes back with the item counted as unchanged, and the menu is not touched at all.

Can I break the menu design?

No. You send facts about items. Layout, fonts, photos, descriptions and section order are managed on the menu side and your calls never reach them.

What happens to new items?

An externalId we have not seen, sent with at least a name and a price, creates the item. Include a category and it lands in the matching section, or creates that section if it is new. No category lands it in a fallback section. Some owners ask us to hold new items for review, in which case the item is created but waits off the menu and the response counts it as staged.

What about item photos?

Two ways. Send an imageUrl in a sync call if you already host the file, or POST the file itself to the image endpoint. A photo the owner set by hand always keeps showing.

What currency do I send?

The menu currency, on every call, as a three-letter code. Call /ping to see which one it is. Prices go in that currency as plain numbers, not in cents. A mismatch rejects the whole call rather than repricing anything.

How fast do changes appear?

Seconds. Send a price change and the next customer to open the menu sees it.

Are there rate limits?

120 requests a minute per menu, shared across all four endpoints. Up to 500 items in a delta call, and up to 25,000 in a full sync so the whole catalog always fits in one call. If many things change at once, one call with all of them beats many small calls.

OpenAPI file

The whole API as a machine-readable spec. Import it into Postman, Insomnia, or a client generator.

Download openapi.yaml

Questions

Stuck on a payload, or want a key for a test menu? WhatsApp us.

ala.menu Catalog Sync API, version 1. Base URL https://ala.menu/api/pos/v1.