PHP2Shell: Is Your Joomla Site Safe? What to Do Today Introducing Next Boards: Kanban project and task boards for Joomla Next Exams 6.2.0: Exam Studio, six visual question types, exam embeds and six new languages Building the Future of Joomla Extensions
NextSoftware
Next Restaurant — Documentation

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.


Two ways in

There are two transports for the same three operations, the same token and the same order engine.

REST (recommended) Query-string (original)
Base /api/index.php/v1/nextrestaurant /index.php?option=com_nextrestaurant&format=json
Style Resource paths and HTTP verbs One path, operation chosen by task=
Types Real arrays, objects and numbers Some fields are JSON-encoded strings
OpenAPI Fully described Cannot be described — see below
Status Current Supported, frozen

Start with REST. It is the surface the OpenAPI document describes, so you can generate a client, load it into Swagger UI or Postman, and have your tooling validate responses for you.

The query-string lane still works exactly as it always has and is not going away — but it cannot be expressed in OpenAPI at all. The specification keys every operation on a path plus a method, and query parameters play no part in that identity; locations and menu are both GET on /index.php, so a document describing them would have two get operations on one path, which is not expressible. That limitation is the reason the REST routes exist.

The two lanes are documented together below: each endpoint shows the REST form first, with the query-string equivalent underneath.


Enabling it

  1. Go to Apps and turn on the API module.
  2. Go to Integrations and press Generate token.
  3. Copy the token immediately — see below.

The Integrations screen with the API token, webhook endpoints, event selector and test-webhook action

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.

Once the API is on, Integrations shows an API health panel that checks the setup for you — see API health below.


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.


API health

With the API switched on, Integrations → API health checks that clients can actually use it:

Check What it means
Authorization header Whether your web server passes Authorization: Bearer through to Joomla
API token Whether a token has been generated at all
REST routes Whether the Web Services - Next Restaurant plugin is enabled; without it /api/index.php/v1/nextrestaurant/… answers 404
HTTPS Whether the site is served over HTTPS, so the token isn't sent in clear

The first three stop every client, so they are also raised as a banner across the whole admin, for users who can manage settings, until they are fixed. Re-check runs the header test again immediately; it also re-runs by itself once a day.

When the web server drops the Authorization header

Some servers remove the Authorization header before PHP ever sees it. Every bearer request then fails with 401 Invalid API token, exactly as if the token were wrong. It is a server setting, not something the extension can work around:

Server Drops the header?
nginx + PHP-FPM No
Apache + mod_php No
Apache + PHP-FPM or mod_fcgid (FastCGI) Yes, unless configured

The API health panel shows the fix for your server. On Apache, any one of these works:

  1. Rename htaccess.txt to .htaccess in the site root. Joomla's own file already contains the fix (and you likely want it for SEF URLs anyway).

  2. Or add this line to the virtual host or .htaccess (Apache 2.4.13 or later):

    CGIPassAuth On
  3. Or, on mod_fcgid, add FcgidPassHeader Authorization to the virtual host.

On nginx, if a custom configuration strips it, add to the PHP location block:

fastcgi_param HTTP_AUTHORIZATION $http_authorization;

Until the server is fixed, clients can send the token as X-Api-Token: <your-api-token> instead — custom headers are never stripped.


Conventions

Base URL.

REST          https://your-site.example/api/index.php/v1/nextrestaurant
Query-string  https://your-site.example/index.php?option=com_nextrestaurant&format=json&task=publicApi.<endpoint>

Response envelope. The two lanes wrap their payloads differently.

// REST — the resource under `data`, and nothing else
{ "data": { } }

// Query-string — Joomla's envelope, with `success` repeated inside
{ "success": true, "message": null, "messages": null, "data": { } }

Either way the payload you want is in data. Read data, not the outer object.

Errors carry a real HTTP status on both lanes. Do not parse the message to detect failure: check the status code, then read the code.

// REST — the same shape the Joomla API application uses for its own errors
{ "errors": [ { "title": "Unknown location", "code": "UNKNOWN_LOCATION" } ] }

// Query-string
{ "success": false, "message": "Unknown location", "data": { "success": false } }

Money and numbers. On REST everything numeric is a JSON number. On the query-string lane the top-level grand_total is a number while the fields inside data.order are strings with two decimals ("31.50") — parse defensively there.

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

Purpose REST Query-string
Published locations GET /locations task=publicApi.locations
A location's live menu GET /locations/{id}/menu task=publicApi.menu&location_id={id}
Place an order POST /orders task=publicApi.createOrder

Locations

REST          GET /api/index.php/v1/nextrestaurant/locations
Query-string  GET /index.php?option=com_nextrestaurant&format=json&task=publicApi.locations
curl -s "https://your-site.example/api/index.php/v1/nextrestaurant/locations" \
  -H "Authorization: Bearer $NR_TOKEN"

Returns every published location. Unpublished ones are never listed.

On REST, service_modes is an array, opening_hours an object and tax_rate a number:

{
  "data": [
    {
      "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" },
        "sat": { "open": "10:00", "close": "23:30" }
      },
      "tax_rate": 8.25
    }
  ]
}

The query-string lane returns the same data exactly as stored, which means two of those fields arrive as JSON-encoded strings and need a second decode:

{
  "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
    }
  ]
}

opening_hours is keyed by mon–sun on both lanes; a day that is absent or empty means closed. The query-string lane also carries a state field, which is always 1 because unpublished locations are never returned.

The id from here is the location id every other endpoint needs.


REST          GET /api/index.php/v1/nextrestaurant/locations/{id}/menu
Query-string  GET /index.php?option=com_nextrestaurant&format=json&task=publicApi.menu&location_id={id}
curl -s "https://your-site.example/api/index.php/v1/nextrestaurant/locations/2/menu" \
  -H "Authorization: Bearer $NR_TOKEN"
Parameter Required Meaning
id (REST path) / location_id (query-string) Yes A published location's id

Both lanes return the same item structure. On REST it is {"data": {"location_id": …, "categories": [ … ]}}; on the query-string lane the same object sits inside Joomla's envelope.

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

REST          POST /api/index.php/v1/nextrestaurant/orders      -> 201 Created
Query-string  POST /index.php?...&task=publicApi.createOrder    -> 200 OK
Content-Type: application/json
curl -s -X POST "https://your-site.example/api/index.php/v1/nextrestaurant/orders" \
  -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.currency is often null. 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

On REST, every failure is {"errors": [{"title": …, "code": …}]}:

Status code When
400 INVALID_ORDER Missing input, or an item is unpublished, 86'd or unavailable there
401 INVALID_TOKEN Token missing, wrong, or none has been generated
403 API_DISABLED The Enable API checkbox is off under Integrations
403 MODULE_DISABLED The API module is off under Apps
404 UNKNOWN_LOCATION The location does not exist or is not published. Applies to the menu and to creating an order — omitting location_id entirely is the 400 above, not this
404 404 No such route, or the wrong verb for it — this one comes from Joomla's router, so code is a number
429 RATE_LIMITED Too many orders from one address
500 ORDER_FAILED Something failed server-side

On the query-string lane the same conditions produce Joomla's envelope instead:

Status Body
400 {"success": false, "message": "location_id is required"}
401 {"success": false, "message": "Invalid API token"}
403 {"success": false, "message": "API is disabled"}
403 {"success": false, "message": "This module is disabled.", "data": {"code": "MODULE_DISABLED"}}
404 {"success": false, "message": "Unknown location"}
404 {"success": false, "message": "Unknown task.", "data": {"code": "UNKNOWN_TASK"}}
429 {"success": false, "message": "Too many orders …", "data": {"code": "RATE_LIMITED"}}
500 {"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.


OpenAPI

The REST lane ships a machine-readable description in OpenAPI 3.1, and your own site serves it:

https://your-site.example/media/com_nextrestaurant/openapi.yaml

That is the copy installed with your version of the extension, so it describes the API that site actually exposes rather than whatever the current release documents. Load it into Swagger UI, Redoc, Postman or Insomnia, or generate a typed client:

# Interactive docs, no install
npx @redocly/cli preview-docs https://your-site.example/media/com_nextrestaurant/openapi.yaml

# A TypeScript client
npx openapi-typescript https://your-site.example/media/com_nextrestaurant/openapi.yaml -o api.d.ts

Point the host server variable at your site. Both authentication schemes are declared, so a Swagger UI "Authorize" box accepts your token and the built-in Try it calls work against a live site.

The document is verified against a running site on every test run — each documented response is validated against its schema — so it describes what the API returns rather than what it was intended to return.

Only the REST lane is described. The query-string lane cannot be, for the reason given at the top of this page.


CORS

Both lanes emit CORS headers so a browser-based front end on another origin can call them, and both answer a 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. The same setting governs both lanes.

Error responses carry the headers too. Without that, a browser hands your JavaScript an opaque network failure with no status to read, and a wrong token looks exactly like the server being down.

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/api/index.php/v1/nextrestaurant"
AUTH="Authorization: Bearer $NR_TOKEN"

# 1. Which branches exist?
curl -s "$BASE/locations" -H "$AUTH" | jq '.data[] | {id, title, currency}'

# 2. What can branch 2 sell right now?
curl -s "$BASE/locations/2/menu" -H "$AUTH" \
  | jq '.data.categories[].items[] | {id, title, price, has_variations}'

# 3. What are the options on item 16?
curl -s "$BASE/locations/2/menu" -H "$AUTH" \
  | jq '.data.categories[].items[] | select(.id == 16) | {variations, modifier_groups}'

# 4. Place the order (201 Created).
curl -s -X POST "$BASE/orders" -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 available at 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 createOrder that 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_number from 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.
  • Timestamps are UTC. placed_at and other instants are returned in UTC; convert them with the location's timezone. See Timezones & venue time.

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

All Extensions