REST API
A small public REST surface for integrating what you run alongside Next Restaurant — a kiosk, a mobile app, a partner's ordering front end.
It is off by default. Public endpoints are reachable without an admin session, so they are opt-in rather than opt-out.
The surface is deliberately narrow: read the locations, read the live menu, place an order. It is not a remote-administration interface, and it is not a reporting interface.
Enabling it
- Go to Apps and turn on the API module.
- Go to Integrations and press Generate token.
- Copy the token immediately — see below.

There are two switches and both must be on. The API module under Apps is the master shutoff; the Enable API checkbox under Integrations is the day-to-day one. Turning either off refuses every request, whoever holds the token.
Authentication
Every request carries a bearer token:
Authorization: Bearer <your-api-token>
X-Api-Token: <your-api-token> is accepted as an alternative for clients that cannot set an
Authorization header.
The token is shown exactly once, when you generate it. Only a SHA-256 hash is stored, so it cannot be recovered later — if you lose it, generate a new one. Generating invalidates the previous token immediately, which is also the recovery path if one leaks.
There is one token per site. It is not scoped per integration, so two consumers sharing a site share a token, and regenerating cuts both off.
Conventions
Base URL. Every endpoint is the same URL with a different task:
https://your-site.example/index.php?option=com_nextrestaurant&format=json&task=publicApi.<endpoint>
Response envelope. Every response — success or failure — has this shape:
{ "success": true, "message": null, "messages": null, "data": { } }
The payload you want is always in data, and data repeats success inside it. Read
data, not the outer object.
Errors carry a real HTTP status. Do not parse the message to detect failure; check the status
code, then read data.message for something to show a human and data.code where present.
Money and numbers. Top-level grand_total is a JSON number; the fields inside data.order are
strings with two decimals ("31.50"). Parse defensively — coerce both.
Ignore fields you do not recognise. New fields are added over time; a consumer that rejects unknown keys will break on an upgrade it did not need to care about.
Endpoints
| Endpoint | Method | Purpose |
|---|---|---|
publicApi.locations |
GET | Published locations and their public details |
publicApi.menu |
GET | The live, orderable menu for one location |
publicApi.createOrder |
POST | Place an order |
Locations
GET ?option=com_nextrestaurant&format=json&task=publicApi.locations
curl -s "https://your-site.example/index.php?option=com_nextrestaurant&format=json&task=publicApi.locations" \
-H "Authorization: Bearer $NR_TOKEN"
Returns every published location. Unpublished ones are never listed.
{
"success": true,
"locations": [
{
"id": 1,
"code": "MAIN",
"title": "Downtown Bistro",
"currency": "USD",
"timezone": "America/Chicago",
"service_modes": "[\"dine_in\",\"takeaway\",\"delivery\",\"qr\"]",
"opening_hours": "{\"mon\":{\"open\":\"11:00\",\"close\":\"22:00\"}}",
"tax_rate": "8.25",
"state": 1
}
]
}
Note:
service_modesandopening_hoursare returned as JSON-encoded strings, not nested objects — decode them a second time on your side.opening_hoursis keyed bymon–sun; a day that is absent or empty means closed.
The id from here is the location_id every other endpoint needs.
Menu
GET ?option=com_nextrestaurant&format=json&task=publicApi.menu&location_id=<id>
curl -s "https://your-site.example/index.php?option=com_nextrestaurant&format=json&task=publicApi.menu&location_id=2" \
-H "Authorization: Bearer $NR_TOKEN"
| Parameter | Required | Meaning |
|---|---|---|
location_id |
Yes | A published location's id |
The menu reflects live availability for that location: unpublished items, items switched off for that branch, and 86'd items are all absent, and the price is the branch's price where one overrides the base. An integration therefore cannot sell something you cannot make.
Items are grouped into categories, in the order you arranged them in the admin.
{
"success": true,
"location_id": 2,
"categories": [
{
"id": 2,
"title": "Starters",
"items": [
{
"id": 1,
"title": "Garlic Bread",
"description": "Stone-baked sourdough with roasted garlic butter, melted mozzarella and parsley.",
"price": 6.5,
"has_variations": 0,
"image": "images/nextrestaurant/items/1/gallery/cb901db407fa4277.jpg",
"prep_time_minutes": 19,
"calories": null,
"spice_level": 0,
"is_alcohol": 0,
"variations": [],
"modifier_groups": [],
"allergens": [
{ "id": 1, "title": "Celery", "presence": "contains" }
]
}
]
},
{
"id": 5,
"title": "Pizza",
"items": [
{
"id": 16,
"title": "Margherita Pizza",
"description": "San Marzano tomato, fior di latte and fresh basil on a 48-hour dough.",
"price": 13,
"has_variations": 1,
"image": "images/nextrestaurant/items/16/gallery/ca98f3a212f1aac0.jpg",
"prep_time_minutes": 23,
"calories": null,
"spice_level": 0,
"is_alcohol": 0,
"variations": [
{ "id": 1, "title": "Regular", "price": 13, "is_default": 1 },
{ "id": 2, "title": "Large", "price": 17, "is_default": 0 }
],
"modifier_groups": [
{
"id": 2,
"title": "Extra Toppings",
"selection_type": "multiple",
"is_required": 0,
"min_select": 0,
"max_select": 4,
"free_quantity": 0,
"modifiers": [
{ "id": 5, "title": "Extra Cheese", "price": 1.5, "is_default": 0, "max_quantity": 1 },
{ "id": 6, "title": "Mushrooms", "price": 1, "is_default": 0, "max_quantity": 1 },
{ "id": 7, "title": "Olives", "price": 1, "is_default": 0, "max_quantity": 1 },
{ "id": 8, "title": "Jalapenos", "price": 0.75, "is_default": 0, "max_quantity": 1 }
]
}
],
"allergens": [
{ "id": 5, "title": "Fish", "presence": "contains" },
{ "id": 14, "title": "Sulphur dioxide / sulphites", "presence": "contains" }
]
}
]
}
]
}
Item fields
| Field | Meaning |
|---|---|
price |
The branch price for the item as ordered with no variation |
has_variations |
1 when the item is sold in sizes or versions |
image |
Path relative to the site root, or null. Prefix with your site URL to display it |
prep_time_minutes, calories, spice_level, is_alcohol |
Optional presentation data; any may be null or 0 |
variations |
Sizes/versions. Empty when the item has none |
modifier_groups |
Option groups attached to this item |
allergens |
Declared allergens; presence is contains or may_contain |
Variations
When variations is non-empty, pick one and send its id. The variation's price replaces the
item's price — it is not added to it. is_default marks the one to pre-select.
Submitting an item that has variations without a variation_id is accepted, and is charged at the
base price with no size recorded. That is almost never what a customer meant, so treat a missing
selection as a validation error in your own UI.
Modifier groups
| Field | Meaning |
|---|---|
selection_type |
single (choose one) or multiple (choose several) |
is_required |
1 when the customer must choose |
min_select / max_select |
How many modifiers may be chosen from this group |
free_quantity |
How many are included before the per-modifier price applies |
modifiers[].max_quantity |
How many times one modifier may be repeated |
min_select and max_select already include any per-item override, so use them as given.
Modifier prices are added to the line.
Create order
POST ?option=com_nextrestaurant&format=json&task=publicApi.createOrder
Content-Type: application/json
curl -s -X POST "https://your-site.example/index.php?option=com_nextrestaurant&format=json&task=publicApi.createOrder" \
-H "Authorization: Bearer $NR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"location_id": 2,
"channel": "web",
"service_mode": "takeaway",
"notes": "Ring the bell at the side door",
"customer": { "name": "Dana Whitfield", "phone": "+1 555 0142", "email": "[email protected]" },
"items": [
{ "item_id": 16, "variation_id": 2, "qty": 1, "notes": "Well done",
"modifiers": [ { "modifier_id": 5, "qty": 1 } ] },
{ "item_id": 1, "qty": 2 }
]
}'
Request body
| Field | Required | Meaning |
|---|---|---|
location_id |
Yes | Where the order is placed |
items |
Yes | At least one line — see below |
channel |
No | web (default), phone, takeaway or delivery. Anything else falls back to web. Recorded on the order and visible in reports |
service_mode |
No | Free text stored with the order, e.g. takeaway, dine_in |
notes |
No | Order-level note, shown to the kitchen |
customer |
No | { name, phone, email } — all optional, stored with the order |
Each entry in items:
| Field | Required | Meaning |
|---|---|---|
item_id |
Yes | Menu item id from the menu endpoint |
qty |
Yes | Quantity |
variation_id |
When the item has variations | Chosen variation |
modifiers |
No | [{ "modifier_id": 5, "qty": 1 }, …] |
notes |
No | Line note, e.g. "no ice" |
A body wrapped as {"data": { … }} is also accepted, for clients that always send that envelope.
Response
The created record, with its priced lines:
{
"success": true,
"id": 580,
"order_number": "ORD-000580",
"grand_total": 34.1,
"status": "placed",
"order": {
"id": 580,
"order_number": "ORD-000580",
"location_id": 2,
"channel": "web",
"status": "placed",
"fulfillment_status": "in_kitchen",
"subtotal": "31.50",
"discount_total": "0.00",
"tax_total": "2.60",
"service_charge": "0.00",
"delivery_fee": "0.00",
"grand_total": "34.10",
"currency": null,
"notes": "Ring the bell at the side door",
"placed_at": "2026-09-07 20:42:51",
"items": [
{
"id": 1290,
"item_id": 16,
"variation_id": 2,
"name_snapshot": "Margherita Pizza (Large)",
"qty": "1.000",
"unit_price": "17.00",
"modifiers_total": "1.50",
"line_total": "18.50",
"notes": "Well done"
},
{
"id": 1291,
"item_id": 1,
"variation_id": null,
"name_snapshot": "Garlic Bread",
"qty": "2.000",
"unit_price": "6.50",
"modifiers_total": "0.00",
"line_total": "13.00",
"notes": null
}
]
}
}
name_snapshot is what the kitchen and the receipt will show — it records the item as it was
priced, including the variation. placed_at is in the site's configured timezone.
Note:
order.currencyis oftennull. Take the currency from the location instead.
What the totals mean
Totals are recomputed on the server from your menu records. A submitted price is ignored, so a
tampered request buys nothing at the wrong price — and so your integration never has to replicate
your tax or pricing rules. In the example above: 17.00 + 1.50 for the pizza plus 6.50 × 2 for
the bread gives a 31.50 subtotal, and tax brings the grand_total to 34.10.
The order's status is always placed. It cannot be set by the caller.
What an API order becomes
An ordinary order. It routes to kitchen stations by your routing rules, depletes stock at whatever status you configured, settles into the payments ledger, and appears in reports with its channel recorded — exactly like an order from the storefront.
Orders arrive unpaid. The API records an order; it does not take payment. Collect at the counter, on delivery, or through your own checkout.
Errors
| Status | When | Body |
|---|---|---|
400 |
Missing or malformed input | {"success": false, "message": "location_id is required"} |
400 |
An item is unpublished, 86'd or unavailable at that location | {"success": false, "message": "One of the items is no longer available."} |
401 |
Token missing, wrong, or none has been generated | {"success": false, "message": "Invalid API token"} |
403 |
The Enable API checkbox is off | {"success": false, "message": "API is disabled"} |
403 |
The API module is off under Apps | {"success": false, "message": "This module is disabled.", "data": {"code": "MODULE_DISABLED"}} |
404 |
location_id does not exist or is unpublished |
{"success": false, "message": "Unknown location"} |
404 |
Endpoint name misspelled | {"success": false, "message": "Unknown task.", "data": {"code": "UNKNOWN_TASK"}} |
429 |
Too many orders from one address | {"success": false, "message": "Too many orders from this address. Try again shortly.", "data": {"code": "RATE_LIMITED"}} |
500 |
Something failed server-side | {"success": false, "message": "The order could not be created."} |
The two 403s are different switches — check both Apps and Integrations when you see one.
A 500 is deliberately vague to the caller; the detail is written to the Joomla log
(administrator/logs/), which is where to look when an order will not go through.
Rate limits
Order creation is limited to 60 orders per IP address per 5 minutes. Beyond that you get 429
and no order is created — the response is safe to retry after a pause.
The read endpoints are not rate limited, but they are not free either: fetch the menu on a timer or on cache miss, not on every page view.
CORS
The API emits CORS headers so a browser-based front end on another origin can call it, and answers
preflight OPTIONS with 204:
Access-Control-Allow-Origin: *
Access-Control-Allow-Headers: Authorization, X-Api-Token, Content-Type, X-CSRF-Token
Access-Control-Allow-Methods: GET, POST, OPTIONS
The allowed origin defaults to *. Set Allowed CORS origins in Integrations to a comma-separated
list to narrow it.
Calling the API from browser JavaScript puts your token in the page, where any visitor can read it and place orders with it. Restricting the origin does not prevent that — CORS is enforced by browsers, not by your server, and a token lifted from a page can be replayed with
curl. Call the API from your own backend and let your front end talk to that.
A worked example
Placing an order end to end, in the order a kiosk would do it:
BASE="https://your-site.example/index.php?option=com_nextrestaurant&format=json"
AUTH="Authorization: Bearer $NR_TOKEN"
# 1. Which branches exist?
curl -s "$BASE&task=publicApi.locations" -H "$AUTH" | jq '.data.locations[] | {id, title}'
# 2. What can branch 2 sell right now?
curl -s "$BASE&task=publicApi.menu&location_id=2" -H "$AUTH" \
| jq '.data.categories[].items[] | {id, title, price, has_variations}'
# 3. What are the options on item 16?
curl -s "$BASE&task=publicApi.menu&location_id=2" -H "$AUTH" \
| jq '.data.categories[].items[] | select(.id == 16) | {variations, modifier_groups}'
# 4. Place the order.
curl -s -X POST "$BASE&task=publicApi.createOrder" -H "$AUTH" \
-H "Content-Type: application/json" \
-d '{"location_id":2,"items":[{"item_id":16,"variation_id":2,"qty":1}]}' \
| jq '.data | {order_number, grand_total, status}'
Practical advice
- Cache the menu, briefly. It changes when staff 86 an item, so a 30–60 second cache keeps the kiosk responsive without selling something the kitchen ran out of ten minutes ago.
- Re-fetch the menu before submitting a long-lived basket, and re-check the ids you are about
to send. A
400 One of the items is no longer availableat checkout is the item having gone off sale while the customer was deciding. - Retrying is not free. There is no idempotency key, so a retried
createOrderthat actually succeeded the first time creates a second order. On a timeout, treat the outcome as unknown and reconcile rather than blindly retrying. - Use webhooks for "what happened next". The API has no endpoint to read an order back after
it is created, so keep the
order_numberfrom the response and subscribe to Webhooks for status changes. Polling is the wrong shape here, and there is nothing to poll.
What the API is not
Being explicit avoids disappointment:
- It is not an admin API. There is no endpoint to edit menu items, manage staff, or change settings. Administration happens in the admin.
- It is not a reporting API. Reports are read in the admin, or exported as CSV.
- It cannot read orders back. There is no order-status endpoint. Use webhooks.
- It does not take payment. Orders arrive unpaid.
- It does not replace webhooks. For "tell me when something happens", use Webhooks.
Security notes
- Treat the token as a credential. Anyone holding it can read your menu and place orders.
- Keep it server-side. See the CORS warning above.
- Use HTTPS. The token transits on every request.
- Regenerate on suspicion. One click, and the old token dies immediately.
- Turn the module off if you're not using it. An unused enabled API is a surface for no benefit.
See also
- Webhooks — outbound events, and the only way to follow an order
- Apps & Modules — enabling the API module
- Online Ordering — what an API order becomes
- Menu — variations, modifier groups and allergens in the admin