openapi: 3.1.0
info:
title: REVO API
version: "1.1"
summary: Read a restaurant's menu, locations, sales and stock from REVO, and send orders that ring on its POS.
description: |
The REVO API connects a restaurant's other software to **REVO**, the POS and back office it runs on:
its website, its own ordering app, its accounting, a BI dashboard, a stock or loyalty tool.
- **Read** the menu and products, the locations and their opening hours, sales (paid orders and
totals per day and per product) and stock (ingredients and their current stock per location).
- **Order** (Comenzi online): show the live menu, place orders that ring on the restaurant's POS, and
follow them by webhook or polling.
Developer guide, code samples and changelog: .
## Keys and scopes
The restaurant creates keys in **REVO → Setări → Chei API**. A key belongs to **one restaurant** and
only ever sees that restaurant's data. It may be limited to some of its locations, or cover all of
them (including locations added later). Send it on every request:
```
Authorization: Bearer rvo_live_7Kq2…
```
The key is shown **once**, when it is created. Keep it on your server, never in a web page or an app
binary. What a key may use is set by its **scopes**:
| Scope | REVO label | Gives |
|---|---|---|
| `locations:read` | Locații | `GET /locations`, `GET /locations/{id}` |
| `menu:read` | Meniu și produse | `/categories`, `/products`, `/allergens` |
| `sales:read` | Vânzări | `/sales/*`: paid orders, totals per day and per product. No customer data. |
| `stock:read` | Stocuri | `/ingredients`: ingredients and current stock per location |
| `orders:write` | Comenzi online | `/locations/{id}/menu`, `/orders…`, `/webhooks/test` and order webhooks |
A call outside the key's scopes answers `403 insufficient_scope`. `GET /me` needs no scope and tells
you the key's scopes and locations. Each operation below lists the scope it needs (`x-revo-scope`).
Keys start with `rvo_live_` or `rvo_test_`. **Test mode only concerns ordering**: test orders go
through the same checks but never reach the kitchen, the fiscal printer, stock or reports. With read
scopes a test key reads the restaurant's real data.
## Conventions
- JSON in UTF-8. Send `Accept: application/json` (and `Content-Type: application/json` with a body).
- **Money**: decimal strings in RON with exactly two decimals, e.g. `"24.50"`.
- **Quantities**: decimal strings, e.g. `"1.250"` (kg) or `"2.00"`.
- **Instants**: ISO 8601 in UTC, e.g. `"2026-10-05T16:30:00Z"`. An instant you send may carry an
offset (`+03:00`); without one it is UTC. Accepted from `0001-01-01T00:00:00Z` (the zero time of Go
and .NET, fine as "from the start") to `9999-12-31T23:59:59Z`. **Dates** (`from`, `to`, `date`) are
calendar days in `Europe/Bucharest`, the restaurant's time zone, from `1900-01-01` to `9999-12-31`.
Anything else is `422`.
- **Ids** are opaque strings. Store them as strings; never parse them.
- **Versioning**: v1 only ever gains fields, parameters and endpoints. Ignore fields you do not know.
A breaking change would ship as `/v2`, with at least 6 months of overlap.
## Pagination and sync
Lists (`/categories`, `/products`, `/ingredients`, `/sales/orders`) answer
`{"data": [...], "next_cursor": "…", "has_more": true}`. Pass `next_cursor` back as `cursor` —
together with the same filters as the first call — until `has_more` is `false`. `limit` is 1–200
(default 50). A cursor is opaque; a changed or broken one answers `422`.
Rows come oldest change first. A row that does not change while you walk comes exactly once. A row
that changes during the walk moves to the end and comes **again**, with its new values — so treat
every row as an upsert, never as "new".
To keep a copy in sync, send `updated_since` (an instant): you get only what changed at or after it.
Rows deleted in REVO then come back as **tombstones** — `{"id": "…", "deleted": true, "deleted_at": "…"}`
— so you can remove them. Keep the newest `updated_at` you received and start the next sync from it
**minus two minutes**: a change that was still being saved while you read is then caught by the next
sync (a few rows come twice; that is harmless). Run a full resync now and then (for example nightly);
it also picks up a product or ingredient the restaurant moved to another location, which a sync
limited to some locations (by `location_id`, or by a key limited to them) cannot see.
A sync cannot filter on a value that changes with the data — `available` and `category_id` on
`/products`, `below_min` on `/ingredients`: a product that goes on 86, or an ingredient that is
restocked, would stop matching and never reach your copy. Combined with `updated_since` or
`include_deleted`, those filters answer `422`; sync everything and filter on your side.
## Errors
Every error has the same envelope:
```json
{"error": {"code": "insufficient_scope", "message": "…", "display_message": "…", "details": {}}}
```
`message` is for developers (English); `display_message` is Romanian text an app may show its user.
The ordering routes keep the texts written for an online customer ("…nu primește comenzi din
aplicație"); data routes answer in neutral words ("Cheia nu are acces la această locație.").
## Limits
120 requests per minute per key, and 600 per minute for all keys of one restaurant together. On top:
30 `POST /orders` and 30 calls of `/sales/daily` + `/sales/products` per minute per key. Over a limit
the answer is `429 rate_limited` with `Retry-After` (seconds). Every answer to a valid, active key —
errors included — carries `X-RateLimit-Limit` and `X-RateLimit-Remaining` of the tightest limit that
applies (normally the per-key 120; on the two sales aggregates their 30). Answers given before that
(`401`, `403 api_key_disabled`, `503`, or a URL that is no endpoint) carry none. After 30 requests
with a missing or wrong key in a minute from one address (the address that connects to REVO), that
address is refused with `429` for the rest of that minute; a valid key is never held back by it.
Request bodies are at most 64 KB.
## Browsers (CORS)
The API is meant to be called from your server. Browsers may call it only from REVO's own pages
(the interactive reference on revoapp.ro uses this for "Try it"); a website calls REVO from its
backend.
contact:
name: REVO
url: https://revoapp.ro/developers/
email: contact@revoapp.ro
servers:
- url: https://api.revoapp.ro/api/public/v1
description: Production
security:
- bearerAuth: []
tags:
- name: Start
description: Check a key.
- name: Locations
description: The restaurant's locations, with opening hours and online-ordering status.
- name: Menu and products
description: |
The whole catalogue (`menu:read`): every product of the allowed locations with its POS price,
each channel's price, availability (86), allergens, variants and extras.
- name: Sales
description: |
Sales (`sales:read`) with exactly the rules of the restaurant's REVO dashboard (Panou de bord):
an order counts when it is **paid** and not cancelled; its amount is **net_sales = total − tip −
SGR deposit** (= items − order discount + delivery fee); it counts on the Bucharest calendar day it
was **opened** (`created_at`; `paid_at` is there if you prefer to re-bucket). Refunds are reported
on the day they were made and are not subtracted. **No customer data**, no notes, no staff.
- name: Stock
description: Ingredients and their current shelf stock per location (`stock:read`). No costs.
- name: Ordering
description: |
Comenzi online (`orders:write`): the orderable menu, orders that ring on the POS, their status.
These routes answer `403 api_disabled` while the restaurant has online ordering switched off.
The full ordering guide: .
- name: Webhooks
description: Order status events sent to the webhook URL of an `orders:write` key.
x-tagGroups:
- name: Data
tags: [Start, Locations, Menu and products, Sales, Stock]
- name: Online ordering
tags: [Ordering, Webhooks]
paths:
/me:
get:
tags: [Start]
operationId: getMe
summary: Check the key
description: Which key, restaurant and locations it serves, its scopes and limits. Needs no scope; works even while online ordering is off.
x-revo-scope: []
responses:
"200":
description: The key.
headers:
X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
content:
application/json:
schema: { $ref: "#/components/schemas/Me" }
example:
app: { id: "4f6c2a1e-…", name: "Contabilitate", mode: "live" }
business: { id: "019f8012-…", name: "Bistro" }
scopes: ["menu:read", "sales:read"]
location_ids: ["9a1b3c4d-…"]
all_locations: false
rate_limits: { per_minute: 120, business_per_minute: 600, heavy_per_minute: 30, orders_per_minute: 30 }
webhook: { url: null, configured: false }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/locations:
get:
tags: [Locations]
operationId: listLocations
summary: List locations
description: |
The restaurant's active locations this key may use, with address, phone, opening hours and the
online-ordering status. A key with only `orders:write` gets `403 api_disabled` while online
ordering is off; with `locations:read` this always works (`ordering.status` is then `disabled`).
x-revo-scope: [locations:read, orders:write]
responses:
"200":
description: The locations.
headers:
X-RateLimit-Limit: { $ref: "#/components/headers/X-RateLimit-Limit" }
X-RateLimit-Remaining: { $ref: "#/components/headers/X-RateLimit-Remaining" }
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Location" }
example:
data:
- &ex-location
id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
name: "Bistro Centru"
address: "Str. Exemplu 12"
city: "București"
phone: "+40 700 000 000"
timezone: Europe/Bucharest
ordering:
accepting: true
status: open
message: null
paused_until: null
next_opening_at: null
types: [pickup, delivery]
default_prep_minutes: 20
answer_within_minutes: 10
opening_hours:
monday: { open: "10:00", close: "23:00", closed: false }
tuesday: { open: "10:00", close: "23:00", closed: false }
wednesday: { open: "10:00", close: "23:00", closed: false }
thursday: { open: "10:00", close: "23:00", closed: false }
friday: { open: "10:00", close: "01:00", closed: false }
saturday: { open: "12:00", close: "01:00", closed: false }
sunday: { open: null, close: null, closed: true }
exceptions:
- { date: "2026-12-25", closed: true, reason: "Crăciun" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/locations/{location_id}:
get:
tags: [Locations]
operationId: getLocation
summary: Get a location
x-revo-scope: [locations:read, orders:write]
parameters:
- $ref: "#/components/parameters/LocationIdPath"
responses:
"200":
description: The location.
content:
application/json:
schema: { $ref: "#/components/schemas/Location" }
example: *ex-location
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/categories:
get:
tags: [Menu and products]
operationId: listCategories
summary: List menu categories
description: Categories of the allowed locations; `location_id` null = shared by every location.
x-revo-scope: [menu:read]
parameters:
- $ref: "#/components/parameters/LocationIdQuery"
- $ref: "#/components/parameters/UpdatedSince"
- $ref: "#/components/parameters/IncludeDeleted"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: A page of categories (tombstones for deleted ones when syncing).
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Page"
- type: object
properties:
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/Category"
- $ref: "#/components/schemas/Tombstone"
examples:
page:
summary: A page of categories
value:
data:
- id: "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f"
deleted: false
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
name: "Pizza"
image_url: "https://api.revoapp.ro/api/uploads/5b7d9f1e-2c4a-4e6b-8d0f-1a3c5e7b9d2f/raw"
position: 1
created_at: "2026-09-01T08:00:00Z"
updated_at: "2026-10-05T09:12:44Z"
- id: "d3e5f7a9-2b4c-4d6e-9f8a-0b1c2d3e4f5a"
deleted: false
location_id: null
name: "Băuturi"
image_url: null
position: 2
created_at: "2026-09-01T08:00:00Z"
updated_at: "2026-09-01T08:00:00Z"
next_cursor: null
has_more: false
sync:
summary: A sync with updated_since (a deleted category comes back as a tombstone)
value:
data:
- { id: "e4f6a8b0-3c5d-4e7f-8a9b-1c2d3e4f5a6b", deleted: true, deleted_at: "2026-10-05T10:02:13Z", updated_at: "2026-10-05T10:02:13Z" }
next_cursor: null
has_more: false
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/products:
get:
tags: [Menu and products]
operationId: listProducts
summary: List products
description: |
Every product of the allowed locations — not only what is offered online — with the POS price,
each channel's price and switch, availability, allergens, variants and extras.
x-revo-scope: [menu:read]
parameters:
- $ref: "#/components/parameters/LocationIdQuery"
- name: category_id
in: query
required: false
description: |
Only this category's products. Not with `updated_since` or `include_deleted` (`422`): a product
moved to another category would never leave your copy — sync everything and filter on your side.
schema: { type: string }
- name: available
in: query
required: false
description: |
`false` = only products currently sold out (86); `true` = only available ones. Not with
`updated_since` or `include_deleted` (`422`): a product that goes on 86 would never leave your
copy — sync everything and filter on your side.
schema: { type: string, enum: ["true", "false"] }
- $ref: "#/components/parameters/UpdatedSince"
- $ref: "#/components/parameters/IncludeDeleted"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: A page of products (tombstones for deleted ones when syncing).
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Page"
- type: object
properties:
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/Product"
- $ref: "#/components/schemas/Tombstone"
examples:
page:
summary: A page of products (one sold by weight)
value:
data:
- &ex-product
id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
deleted: false
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
category_id: "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f"
name: "Margherita"
description: "Sos de roșii, mozzarella, busuioc."
image_url: "https://api.revoapp.ro/api/uploads/7c9e1a3b-5d7f-4b9c-8e0a-2c4e6a8b0d1f/raw"
price: "32.00"
unit: piece
vat_rate: 11
deposit: null
available: true
allergens: [{ code: "gluten", name: "Gluten" }, { code: "dairy", name: "Lactate" }]
diets: ["vegetarian"]
calories: null
prep_minutes: 15
station: kitchen
channels:
online: { enabled: true, price: "32.00" }
glovo: { enabled: true, price: "36.00" }
wolt: { enabled: false, price: "32.00" }
variants:
- { id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_11a2ddfa1b", name: "Medie", price: "32.00", price_delta: "0.00" }
- { id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_8c14c8aed9", name: "Mare", price: "41.00", price_delta: "9.00" }
extra_groups:
- id: "g_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_extra"
name: "Extra"
min: 0
max: null
required: false
options:
- { id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_7830799225", name: "Mozzarella", price: "5.00" }
- { id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_945f70cb38", name: "Ciuperci", price: "4.00" }
position: 1
created_at: "2026-09-01T08:00:00Z"
updated_at: "2026-10-05T09:12:44Z"
- id: "2a4c6e8f-0b1d-4f3a-8c5e-7a9b1d3f5a7c"
deleted: false
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
category_id: "f5a7b9c1-4d6e-4f8a-9b0c-2d3e4f5a6b7c"
name: "Brânză maturată"
description: null
image_url: null
price: "89.00"
unit: kg
vat_rate: 11
deposit: null
available: true
allergens: [{ code: "dairy", name: "Lactate" }]
diets: []
calories: null
prep_minutes: null
station: bar
channels:
online: { enabled: false, price: "89.00" }
glovo: { enabled: false, price: "89.00" }
wolt: { enabled: false, price: "89.00" }
variants: []
extra_groups: []
position: 4
created_at: "2026-09-12T07:30:00Z"
updated_at: "2026-10-02T15:40:09Z"
next_cursor: "eyJ0IjoiMjAyNi0xMC0wMlQxNTo0MDowOS4wMDAwMDAiLCJpIjoiMmE0YzZlOGYtMGIxZC00ZjNhLThjNWUtN2E5YjFkM2Y1YTdjIn0"
has_more: true
sync:
summary: A sync with updated_since (a deleted product comes back as a tombstone)
value:
data:
- { id: "5c7e9a1b-3d5f-4a7c-9e1b-3d5f7a9c1e3b", deleted: true, deleted_at: "2026-10-05T10:02:13Z", updated_at: "2026-10-05T10:02:13Z" }
next_cursor: null
has_more: false
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/products/{product_id}:
get:
tags: [Menu and products]
operationId: getProduct
summary: Get a product
x-revo-scope: [menu:read]
parameters:
- name: product_id
in: path
required: true
schema: { type: string }
responses:
"200":
description: The product.
content:
application/json:
schema: { $ref: "#/components/schemas/Product" }
example: *ex-product
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/allergens:
get:
tags: [Menu and products]
operationId: listAllergens
summary: List the allergen catalogue
description: The restaurant's allergen catalogue — the stable `code` products refer to and its Romanian name.
x-revo-scope: [menu:read]
responses:
"200":
description: The allergens.
content:
application/json:
schema:
type: object
required: [data]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Allergen" }
example:
data: [{ code: "gluten", name: "Gluten" }, { code: "dairy", name: "Lactate" }]
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/sales/daily:
get:
tags: [Sales]
operationId: getDailySales
summary: Sales per day
description: |
One row per calendar day of the range — days without sales included, with zeros — and the
totals. `net_sales` summed over the days equals the dashboard's „Vânzări" for the same range and
location. At most 92 days; 30 calls per minute per key.
x-revo-scope: [sales:read]
parameters:
- $ref: "#/components/parameters/From"
- $ref: "#/components/parameters/To"
- $ref: "#/components/parameters/LocationIdQuery"
responses:
"200":
description: Sales per day.
content:
application/json:
schema: { $ref: "#/components/schemas/DailySales" }
example:
currency: RON
from: "2026-10-03"
to: "2026-10-04"
data:
- date: "2026-10-03"
orders: 214
net_sales: "18340.50"
items: "18620.00"
discounts: "412.50"
delivery_fees: "133.00"
tips: "961.00"
deposits: "57.50"
refunds: "38.00"
payments: { cash: "6088.00", card: "12890.00", card_at_counter: "0.00", online: "381.00", voucher: "0.00", protocol: "0.00", other: "0.00" }
by_type: { dine_in: "15210.50", takeaway: "2210.00", delivery: "920.00" }
by_channel: { pos: "17420.50", glovo: "620.00", wolt: "300.00", direct: "0.00" }
- date: "2026-10-04"
orders: 187
net_sales: "15216.00"
items: "15240.00"
discounts: "120.00"
delivery_fees: "96.00"
tips: "702.00"
deposits: "41.00"
refunds: "0.00"
payments: { cash: "5011.00", card: "10650.00", card_at_counter: "0.00", online: "298.00", voucher: "0.00", protocol: "0.00", other: "0.00" }
by_type: { dine_in: "12730.00", takeaway: "1880.00", delivery: "606.00" }
by_channel: { pos: "14610.00", glovo: "390.00", wolt: "216.00", direct: "0.00" }
totals:
orders: 401
net_sales: "33556.50"
items: "33860.00"
discounts: "532.50"
delivery_fees: "229.00"
tips: "1663.00"
deposits: "98.50"
refunds: "38.00"
payments: { cash: "11099.00", card: "23540.00", card_at_counter: "0.00", online: "679.00", voucher: "0.00", protocol: "0.00", other: "0.00" }
by_type: { dine_in: "27940.50", takeaway: "4090.00", delivery: "1526.00" }
by_channel: { pos: "32030.50", glovo: "1010.00", wolt: "516.00", direct: "0.00" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/sales/products:
get:
tags: [Sales]
operationId: getProductSales
summary: Sales per product
description: |
Line-level sales per product (voided lines left out), highest `net` first: `gross` = quantity ×
unit price + extras, `line_discounts` = the line discounts, `net` = what the lines were sold for.
Order-level discounts are not spread over products (see `/sales/daily`). Lines without a catalogue
product (a free-text line, an unmapped delivery-platform line, a product deleted for good) come as
`product_id: null`, one row per line name. At most 92 days; 30 calls per minute per key.
x-revo-scope: [sales:read]
parameters:
- $ref: "#/components/parameters/From"
- $ref: "#/components/parameters/To"
- $ref: "#/components/parameters/LocationIdQuery"
- name: group_by
in: query
required: false
description: "`period` (default) = one row per product for the whole range; `day` = per product per day."
schema: { type: string, enum: [period, day], default: period }
responses:
"200":
description: Sales per product.
content:
application/json:
schema: { $ref: "#/components/schemas/ProductSales" }
example:
currency: RON
from: "2026-10-03"
to: "2026-10-04"
group_by: period
data:
- product_id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
name: "Margherita"
category_id: "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f"
quantity: "212.000"
weight_kg: null
gross: "6934.00"
line_discounts: "64.00"
net: "6870.00"
- product_id: "2a4c6e8f-0b1d-4f3a-8c5e-7a9b1d3f5a7c"
name: "Brânză maturată"
category_id: "f5a7b9c1-4d6e-4f8a-9b0c-2d3e4f5a6b7c"
quantity: "38.000"
weight_kg: "6.840"
gross: "608.76"
line_discounts: "0.00"
net: "608.76"
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/sales/orders:
get:
tags: [Sales]
operationId: listSalesOrders
summary: List paid orders
description: |
Two ways to call it:
- **A date range** (`from` / `to`, default the last 7 days, at most 92): the paid orders opened in
it, oldest first.
- **A mirror** (`updated_since`): every order changed since then. One that does not (or no longer)
count as a sale comes back as a tombstone with its `reason` (`cancelled`, `unpaid`, `deleted`);
remove it from your copy if you have it, ignore it otherwise.
Use one or the other, not both. With a date range, `include_deleted=true` adds the paid orders of
the range deleted since, as tombstones (`reason: deleted`). Includes POS, Glovo, Wolt and online
orders (`channel`).
x-revo-scope: [sales:read]
parameters:
- $ref: "#/components/parameters/From"
- $ref: "#/components/parameters/To"
- $ref: "#/components/parameters/UpdatedSince"
- $ref: "#/components/parameters/IncludeDeleted"
- $ref: "#/components/parameters/LocationIdQuery"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: A page of orders.
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Page"
- type: object
properties:
from: { type: string, format: date, description: Present in range mode. }
to: { type: string, format: date, description: Present in range mode. }
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/SalesOrder"
- $ref: "#/components/schemas/SalesOrderTombstone"
examples:
range:
summary: A date range (from / to)
value:
from: "2026-10-03"
to: "2026-10-03"
data:
- &ex-sales-order
id: "3f5a7c9e-1b3d-4f5a-8c7e-9b1d3f5a7c9e"
deleted: false
number: "20261003-042"
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
channel: pos
type: dine_in
table_id: "6b8d0f2a-4c6e-4a8c-9e0b-2d4f6a8c0e2b"
business_day: "2026-10-03"
created_at: "2026-10-03T17:12:40Z"
paid_at: "2026-10-03T18:05:11Z"
updated_at: "2026-10-03T18:05:12Z"
items:
- id: "0a2c4e6f-8b0d-4f2a-9c4e-6a8c0e2b4d6f"
product_id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
name: "Margherita"
variant_id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_8c14c8aed9"
variant_name: "Mare"
quantity: "2.00"
weight_kg: null
price_per_kg: null
unit_price: "41.00"
extras: [{ name: "Mozzarella", price: "5.00" }]
discount_percent: 0
vat_rate: 11
total: "92.00"
- id: "1b3d5f7a-9c1e-4a3b-8d5f-7b9d1f3a5c7e"
product_id: "4d6f8a0c-2e4a-4c6e-8a0c-4e6a8c0e2a4c"
name: "Limonadă"
variant_id: null
variant_name: null
quantity: "2.00"
weight_kg: null
price_per_kg: null
unit_price: "14.50"
extras: []
discount_percent: 10
vat_rate: 21
total: "26.10"
totals: { items: "118.10", discount: "0.00", delivery_fee: "0.00", tip: "12.00", deposit: "1.00", total: "131.10", net_sales: "118.10" }
payments: [{ method: card, amount: "131.10", at: "2026-10-03T18:05:11Z" }]
refunds: []
next_cursor: null
has_more: false
mirror:
summary: A mirror with updated_since (an order cancelled after payment)
value:
data:
- { id: "9e1b3d5f-7a9c-4e1b-8d3f-5a7c9e1b3d5f", deleted: true, reason: cancelled, deleted_at: null, updated_at: "2026-10-03T19:40:02Z" }
next_cursor: null
has_more: false
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/sales/orders/{order_id}:
get:
tags: [Sales]
operationId: getSalesOrder
summary: Get a paid order
description: A paid, not cancelled order of a location this key may use; anything else is `404`.
x-revo-scope: [sales:read]
parameters:
- name: order_id
in: path
required: true
schema: { type: string }
responses:
"200":
description: The order.
content:
application/json:
schema: { $ref: "#/components/schemas/SalesOrder" }
example: *ex-sales-order
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/ingredients:
get:
tags: [Stock]
operationId: listIngredients
summary: List ingredients and stock
description: |
One row per ingredient per location, with the current shelf stock (what REVO's stock journal
holds now: sales, receptions, waste, transfers and counts move it).
x-revo-scope: [stock:read]
parameters:
- $ref: "#/components/parameters/LocationIdQuery"
- name: below_min
in: query
required: false
description: |
`true` = only ingredients at or under their minimum stock (`stock` ≤ `min_stock`, REVO's „sub minim":
one without a minimum counts once it is out of stock); `false` = the others. Not with
`updated_since` or `include_deleted` (`422`): a restocked ingredient would never leave your copy —
sync everything and filter on your side.
schema: { type: string, enum: ["true", "false"] }
- $ref: "#/components/parameters/UpdatedSince"
- $ref: "#/components/parameters/IncludeDeleted"
- $ref: "#/components/parameters/Cursor"
- $ref: "#/components/parameters/Limit"
responses:
"200":
description: A page of ingredients (tombstones for deleted ones when syncing).
content:
application/json:
schema:
allOf:
- $ref: "#/components/schemas/Page"
- type: object
properties:
data:
type: array
items:
oneOf:
- $ref: "#/components/schemas/Ingredient"
- $ref: "#/components/schemas/Tombstone"
example:
data:
- &ex-ingredient
id: "7a9c1e3b-5d7f-4b9c-8e0a-2c4e6a8b0d3f"
deleted: false
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
name: "Făină tip 000"
unit: kg
stock: "18.400"
min_stock: "10.000"
below_min: false
category: { id: "b1d3f5a7-9c1e-4b3d-8f5a-7c9e1b3d5f7a", name: "Făinoase" }
warehouse: { id: "c2e4a6b8-0d2f-4c4e-9a6b-8d0f2a4c6e8b", name: "Bucătărie" }
sku: "FAI-000"
barcode: "5940000000017"
is_prepared: false
allergens: [{ code: "gluten", name: "Gluten" }]
created_at: "2026-09-01T08:00:00Z"
updated_at: "2026-10-05T11:20:31Z"
- id: "8b0d2f4a-6c8e-4a0c-9f1b-3d5f7b9d1f4a"
deleted: false
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
name: "Mozzarella"
unit: kg
stock: "2.150"
min_stock: "3.000"
below_min: true
category: { id: "d3f5b7c9-1e3a-4d5f-8b7c-9e1a3c5e7a9b", name: "Lactate" }
warehouse: { id: "c2e4a6b8-0d2f-4c4e-9a6b-8d0f2a4c6e8b", name: "Bucătărie" }
sku: null
barcode: null
is_prepared: false
allergens: [{ code: "dairy", name: "Lactate" }]
created_at: "2026-09-01T08:00:00Z"
updated_at: "2026-10-05T12:03:57Z"
next_cursor: null
has_more: false
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/ingredients/{ingredient_id}:
get:
tags: [Stock]
operationId: getIngredient
summary: Get an ingredient
x-revo-scope: [stock:read]
parameters:
- name: ingredient_id
in: path
required: true
schema: { type: string }
responses:
"200":
description: The ingredient.
content:
application/json:
schema: { $ref: "#/components/schemas/Ingredient" }
example: *ex-ingredient
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/locations/{location_id}/menu:
get:
tags: [Ordering]
operationId: getLocationMenu
summary: Get the orderable menu of a location
description: |
Only products the restaurant offers online (never a product sold by weight), at their online
price. Cache the menu, but refresh it at least every 15 minutes and right before checkout, with
`If-None-Match` (an unchanged menu answers `304`). Variant and option ids change when the
restaurant renames them.
x-revo-scope: [orders:write]
parameters:
- $ref: "#/components/parameters/LocationIdPath"
- name: If-None-Match
in: header
required: false
description: The `ETag` of the menu you have.
schema: { type: string }
example: '"m-5f2a9c"'
responses:
"200":
description: The menu.
headers:
ETag:
description: Opaque version of the menu; send it back in `If-None-Match`.
schema: { type: string }
content:
application/json:
schema: { $ref: "#/components/schemas/Menu" }
example:
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
currency: RON
updated_at: "2026-10-05T09:12:44Z"
categories:
- id: "c2d4e6f8-1a3b-4c5d-8e7f-9a0b1c2d3e4f"
name: "Pizza"
image_url: "https://api.revoapp.ro/api/uploads/5b7d9f1e-2c4a-4e6b-8d0f-1a3c5e7b9d2f/raw"
position: 1
products:
- id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
name: "Margherita"
description: "Sos de roșii, mozzarella, busuioc."
image_url: "https://api.revoapp.ro/api/uploads/7c9e1a3b-5d7f-4b9c-8e0a-2c4e6a8b0d1f/raw"
price: "32.00"
vat_rate: 11
available: true
allergens: [{ code: "gluten", name: "Gluten" }, { code: "dairy", name: "Lactate" }]
diets: ["vegetarian"]
calories: 0
prep_minutes: 15
deposit: null
variants:
- { id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_11a2ddfa1b", name: "Medie", price: "32.00" }
- { id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_8c14c8aed9", name: "Mare", price: "41.00" }
extra_groups:
- id: "g_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_extra"
name: "Extra"
min: 0
max: null
required: false
options:
- { id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_7830799225", name: "Mozzarella", price: "5.00" }
- { id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_945f70cb38", name: "Ciuperci", price: "4.00" }
position: 1
- id: "d3e5f7a9-2b4c-4d6e-9f8a-0b1c2d3e4f5a"
name: "Băuturi"
image_url: null
position: 2
products:
- id: "4d6f8a0c-2e4a-4c6e-8a0c-4e6a8c0e2a4c"
name: "Limonadă"
description: null
image_url: null
price: "14.50"
vat_rate: 21
available: true
allergens: []
diets: ["vegan"]
calories: 120
prep_minutes: null
deposit: "0.50"
variants: []
extra_groups: []
position: 1
"304":
description: Not modified — the menu matches `If-None-Match`.
headers:
ETag:
schema: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/orders:
post:
tags: [Ordering]
operationId: placeOrder
summary: Place an order
description: |
**Try it with an `rvo_test_` key.** With a live key this places a **real** order that rings on the
restaurant's POS.
REVO checks availability and opening hours, computes the prices itself and answers `201` with the
order in status `received`; the order then rings on the restaurant's POS.
**Idempotency:** generate `external_id` once per checkout and reuse it on every retry. If an order
with that `external_id` already exists for your key, REVO returns it (`200 OK` with header
`Idempotent-Replayed: true`) instead of creating a second one — even if the payload differs.
A product with variants needs a `variant_id` (else `409 items_unavailable` with reason
`variant_required`). When an order is refused (`409` / `422`) nothing is created.
x-revo-scope: [orders:write]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/OrderRequest" }
example:
external_id: "BIS-10234"
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
type: "pickup"
scheduled_for: null
customer: { name: "Ana Pop", phone: "+40722123456", email: "ana@example.com" }
delivery: null
items:
- product_id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
variant_id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_8c14c8aed9"
quantity: 2
options: [{ id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_7830799225", quantity: 1 }]
notes: "fără busuioc"
- product_id: "4d6f8a0c-2e4a-4c6e-8a0c-4e6a8c0e2a4c"
quantity: 1
notes: "Vin în 20 de minute"
allergies: "arahide"
cutlery: false
tip: "5.00"
payment: { status: "paid", method: "card", reference: "pi_3Nf2…" }
expected_total: "112.00"
responses:
"201":
description: Created — the order, in status `received`.
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
example: &ex-order
id: "0b4e6f1c-2d3e-4f5a-8b6c-7d8e9f0a1b2c"
external_id: "BIS-10234"
number: "20261005-042"
pickup_code: "4821"
status: received
test: false
type: pickup
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
scheduled_for: null
placed_at: "2026-10-05T13:02:11Z"
answer_by: "2026-10-05T13:12:11Z"
accepted_at: null
estimated_ready_at: null
ready_at: null
completed_at: null
rejected_at: null
cancelled_at: null
rejection: null
cancellation: null
customer: { name: "Ana Pop", phone: "+40722123456", email: "ana@example.com" }
delivery: null
items: &ex-order-items
- product_id: "8e0f1a2b-3c4d-4e5f-9a6b-7c8d9e0f1a2b"
variant_id: "v_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_8c14c8aed9"
name: "Margherita"
variant_name: "Mare"
quantity: 2
unit_price: "41.00"
options: [{ id: "x_8e0f1a2b3c4d4e5f9a6b7c8d9e0f1a2b_7830799225", name: "Mozzarella", quantity: 1, unit_price: "5.00" }]
notes: "fără busuioc"
deposit: "0.00"
total: "92.00"
- product_id: "4d6f8a0c-2e4a-4c6e-8a0c-4e6a8c0e2a4c"
variant_id: null
name: "Limonadă"
variant_name: null
quantity: 1
unit_price: "14.50"
options: []
notes: null
deposit: "0.50"
total: "14.50"
notes: "Vin în 20 de minute"
allergies: "arahide"
cutlery: false
totals: &ex-order-totals { items: "106.50", deposit: "0.50", tip: "5.00", total: "112.00" }
payment: &ex-order-payment { status: paid, method: card, reference: "pi_3Nf2…" }
created_at: "2026-10-05T13:02:11Z"
updated_at: "2026-10-05T13:02:11Z"
"200":
description: Replay — an order with this `external_id` already exists; it is returned unchanged.
headers:
Idempotent-Replayed:
description: Always `true` on a replay.
schema: { type: string, const: "true" }
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
example: *ex-order
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: |
Refused, nothing created:
- `location_unavailable` — closed, paused, online orders off, or `type` not offered.
`details.reason` = `closed` | `paused` | `disabled` | `type_not_offered`.
- `items_unavailable` — a product, variant or option is unknown, not offered online or sold out.
`details.items` = `[{index, product_id, reason}]`.
- `price_changed` — `expected_total` ≠ REVO's total. Ask the customer to confirm, then retry
with the **same** `external_id` and the new `expected_total`.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
get:
tags: [Ordering]
operationId: findOrders
summary: Find orders your key placed
description: |
Pass exactly one of `external_id` or `updated_since`. With `updated_since` the orders come oldest
first; `next_updated_since` is the value for the next call, `null` when there is nothing more.
Only orders placed through this key; for all of the restaurant's sales use `/sales/orders`.
x-revo-scope: [orders:write]
parameters:
- name: external_id
in: query
required: false
description: Your order id.
schema: { type: string, minLength: 1, maxLength: 64 }
- name: updated_since
in: query
required: false
description: Orders changed at or after this instant (UTC).
schema: { type: string, format: date-time }
example: "2026-10-05T12:00:00Z"
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
responses:
"200":
description: The matching orders.
content:
application/json:
schema:
type: object
required: [data, next_updated_since]
properties:
data:
type: array
items: { $ref: "#/components/schemas/Order" }
next_updated_since:
type: [string, "null"]
format: date-time
example:
data:
- &ex-order-accepted
id: "0b4e6f1c-2d3e-4f5a-8b6c-7d8e9f0a1b2c"
external_id: "BIS-10234"
number: "20261005-042"
pickup_code: "4821"
status: accepted
test: false
type: pickup
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
scheduled_for: null
placed_at: "2026-10-05T13:02:11Z"
answer_by: "2026-10-05T13:12:11Z"
accepted_at: "2026-10-05T13:03:40Z"
estimated_ready_at: "2026-10-05T13:23:40Z"
ready_at: null
completed_at: null
rejected_at: null
cancelled_at: null
rejection: null
cancellation: null
customer: { name: "Ana Pop", phone: "+40722123456", email: "ana@example.com" }
delivery: null
items: *ex-order-items
notes: "Vin în 20 de minute"
allergies: "arahide"
cutlery: false
totals: *ex-order-totals
payment: *ex-order-payment
created_at: "2026-10-05T13:02:11Z"
updated_at: "2026-10-05T13:03:40Z"
next_updated_since: null
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/orders/{order_id}:
get:
tags: [Ordering]
operationId: getOrder
summary: Get an order your key placed
description: While a customer is looking at an order, poll every 15–30 seconds (never faster than every 5 s) and stop at a final status.
x-revo-scope: [orders:write]
parameters:
- $ref: "#/components/parameters/OrderId"
responses:
"200":
description: The order.
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
example: *ex-order-accepted
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/orders/{order_id}/cancel:
post:
tags: [Ordering]
operationId: cancelOrder
summary: Cancel an order before the restaurant accepts it
description: |
Allowed only while the order is `received` → `200` with the order (`status: cancelled`,
`cancellation.by: "customer"`); the ringing stops on the POS. After the restaurant accepted it →
`409 cannot_cancel` (`details.status`); ask the customer to call the restaurant.
x-revo-scope: [orders:write]
parameters:
- $ref: "#/components/parameters/OrderId"
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
reason:
type: [string, "null"]
maxLength: 255
example: { reason: "Clientul a renunțat" }
responses:
"200":
description: The cancelled order.
content:
application/json:
schema: { $ref: "#/components/schemas/Order" }
example:
id: "0b4e6f1c-2d3e-4f5a-8b6c-7d8e9f0a1b2c"
external_id: "BIS-10234"
number: "20261005-042"
pickup_code: "4821"
status: cancelled
test: false
type: pickup
location_id: "9a1b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"
scheduled_for: null
placed_at: "2026-10-05T13:02:11Z"
answer_by: "2026-10-05T13:12:11Z"
accepted_at: null
estimated_ready_at: null
ready_at: null
completed_at: null
rejected_at: null
cancelled_at: "2026-10-05T13:04:02Z"
rejection: null
cancellation: { by: customer, reason: "Clientul a renunțat" }
customer: { name: "Ana Pop", phone: "+40722123456", email: "ana@example.com" }
delivery: null
items: *ex-order-items
notes: "Vin în 20 de minute"
allergies: "arahide"
cutlery: false
totals: *ex-order-totals
payment: *ex-order-payment
created_at: "2026-10-05T13:02:11Z"
updated_at: "2026-10-05T13:04:02Z"
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { $ref: "#/components/responses/NotFound" }
"409":
description: "`cannot_cancel` — the order is no longer `received`."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"422": { $ref: "#/components/responses/ValidationFailed" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
/webhooks/test:
post:
tags: [Webhooks]
operationId: testWebhook
summary: Send a test ping to your webhook
description: Synchronous — the answer says how your endpoint responded (10 s timeout).
x-revo-scope: [orders:write]
responses:
"200":
description: The ping was sent; `ok` says whether your endpoint answered 2xx.
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookTestResult" }
examples:
ok:
summary: Your endpoint answered 2xx
value: { ok: true, http_status: 200, error: null, event_id: "evt_01J9Z7B2C4D6E8F0A1B3C5D7E9" }
failed:
summary: Your endpoint did not answer 2xx
value: { ok: false, http_status: 500, error: "Răspuns HTTP 500.", event_id: "evt_01J9Z7C3D5E7F9A0B2C4D6E8F0" }
"400": { $ref: "#/components/responses/BadRequest" }
"401": { $ref: "#/components/responses/Unauthorized" }
"403": { $ref: "#/components/responses/Forbidden" }
"409":
description: "`webhook_not_configured` — this key has no webhook URL."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
"429": { $ref: "#/components/responses/RateLimited" }
"500": { $ref: "#/components/responses/ServerError" }
"503": { $ref: "#/components/responses/ServiceUnavailable" }
webhooks:
orderEvent:
post:
tags: [Webhooks]
summary: An order changed (or a ping)
description: |
REVO `POST`s every change of the orders your key placed to the webhook URL the restaurant entered
for the key. Events: `order.accepted`, `order.rejected`, `order.ready`, `order.completed`,
`order.cancelled`, and `ping` (from `POST /webhooks/test` or the "Testează webhook" button).
- Answer with any `2xx` within **10 seconds**; do slow work asynchronously.
- Non-2xx answers and timeouts are retried after about 1 min, 5 min, 15 min, 1 h, 6 h and 24 h,
then marked failed (the restaurant sees it in "Activitate").
- Delivery is **at least once** and **not necessarily in order**. Deduplicate by `id`; ignore an
event whose `data.order.updated_at` is older than what you have.
- The URL must be `https://` and publicly reachable (no private IPs). Redirects are not followed.
**Signature:** `Revo-Signature: t=,v1=` where
`v1 = hex(HMAC_SHA256(webhook_secret, "" + "." + ))`. Valid when `v1` matches
in constant time AND `|now − t| ≤ 300` seconds. The secret (`whsec_…`) is shown with the key.
parameters:
- name: User-Agent
in: header
required: true
schema: { type: string, const: "REVO-Webhooks/1.0" }
- name: Revo-Event-Id
in: header
required: true
description: Same as the body's `id`.
schema: { type: string }
- name: Revo-Event-Type
in: header
required: true
schema: { $ref: "#/components/schemas/WebhookEventType" }
- name: Revo-Signature
in: header
required: true
schema: { type: string, pattern: "^t=\\d+,v1=[0-9a-f]{64}$" }
example: "t=1728133351,v1=5f8a0c…"
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/WebhookEvent" }
example:
id: "evt_01J9Z6M4X2K8Q7B3R5T1V0W9YZ"
type: "order.accepted"
created_at: "2026-10-05T13:03:41Z"
test: false
data:
order: *ex-order-accepted
responses:
"2XX":
description: Received. Anything else (or no answer within 10 s) is retried.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
description: "`Authorization: Bearer rvo_live_…` or `rvo_test_…` — created in REVO → Setări → Chei API (or Setări → Comenzi online for an ordering app)."
headers:
X-RateLimit-Limit:
description: Requests allowed per minute by the tightest limit that applies to this request.
schema: { type: integer }
X-RateLimit-Remaining:
description: Requests left in the current minute under that limit.
schema: { type: integer }
Retry-After:
description: Seconds to wait before retrying.
schema: { type: integer }
parameters:
LocationIdPath:
name: location_id
in: path
required: true
description: From `GET /locations`.
schema: { type: string }
LocationIdQuery:
name: location_id
in: query
required: false
description: Only this location. Leave it out for every location the key may use. A location of another key's restriction answers `403 location_forbidden`.
schema: { type: string }
OrderId:
name: order_id
in: path
required: true
description: REVO's order `id` (not your `external_id`).
schema: { type: string }
UpdatedSince:
name: updated_since
in: query
required: false
description: |
Sync — only rows changed at or after this instant; deleted rows come back as tombstones. ISO 8601,
e.g. `2026-10-05T12:00:00Z` (an offset such as `+03:00` is fine; without one it is UTC), from
`0001-01-01T00:00:00Z` to `9999-12-31T23:59:59Z` — anything else is `422`. Not with filters that
change with the data (see each list).
schema: { type: string, format: date-time }
example: "2026-10-05T12:00:00Z"
IncludeDeleted:
name: include_deleted
in: query
required: false
description: "`true` = include tombstones of deleted rows even without `updated_since`."
schema: { type: string, enum: ["true", "false"] }
Cursor:
name: cursor
in: query
required: false
description: The `next_cursor` of the previous page, unchanged — send the same filters with it.
schema: { type: string }
Limit:
name: limit
in: query
required: false
description: Rows per page.
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
From:
name: from
in: query
required: false
description: "First day (Europe/Bucharest calendar day), included. Default: 6 days before `to`."
schema: { type: string, format: date }
example: "2026-10-01"
To:
name: to
in: query
required: false
description: Last day, included. Default today. At most 92 days from `from`.
schema: { type: string, format: date }
example: "2026-10-07"
responses:
BadRequest:
description: "`bad_request` — the body is not a valid JSON object, or is larger than 64 KB."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
Unauthorized:
description: "`unauthorized` — missing or wrong key (or a key deleted by the restaurant)."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
example:
error: { code: unauthorized, message: "Missing or invalid API key.", display_message: "Aplicația nu este conectată la restaurant.", details: {} }
Forbidden:
description: |
- `insufficient_scope` — the key does not have the scope this endpoint needs (`details.required_scope`, `details.granted_scopes`).
- `api_key_disabled` — the restaurant disabled this key.
- `api_disabled` — the restaurant switched online ordering off (ordering routes).
- `location_forbidden` — this key is limited to other locations.
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
example:
error:
code: insufficient_scope
message: "This API key does not have the sales:read scope."
display_message: "Aplicația nu are acces la aceste date."
details: { required_scope: "sales:read", required_scopes: ["sales:read"], granted_scopes: ["menu:read"] }
NotFound:
description: "`not_found` — no such id in this restaurant (or in the locations this key may use)."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ValidationFailed:
description: "`validation_failed` — a malformed request; `details.fields` = `{\"items.0.quantity\": [\"…\"]}`."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
RateLimited:
description: "`rate_limited` — see `Retry-After`."
headers:
Retry-After: { $ref: "#/components/headers/Retry-After" }
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ServerError:
description: "`server_error` — retry later (for `POST /orders`, with the same `external_id`)."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
ServiceUnavailable:
description: "`service_unavailable` — temporarily unavailable; retry later."
content:
application/json:
schema: { $ref: "#/components/schemas/Error" }
schemas:
Money:
type: string
pattern: "^-?\\d+\\.\\d{2}$"
description: Decimal string in RON with exactly two decimals.
examples: ["24.50"]
Quantity:
type: string
pattern: "^-?\\d+\\.\\d+$"
description: Decimal string.
examples: ["1.250"]
Timestamp:
type: string
format: date-time
description: ISO 8601 in UTC.
examples: ["2026-10-05T13:02:11Z"]
NullableTimestamp:
type: [string, "null"]
format: date-time
Scope:
type: string
enum: [locations:read, menu:read, sales:read, stock:read, orders:write]
ErrorCode:
type: string
enum:
- bad_request
- unauthorized
- api_key_disabled
- api_disabled
- insufficient_scope
- location_forbidden
- not_found
- location_unavailable
- items_unavailable
- price_changed
- cannot_cancel
- webhook_not_configured
- validation_failed
- rate_limited
- server_error
- service_unavailable
Error:
type: object
required: [error]
properties:
error:
type: object
required: [code, message, display_message, details]
properties:
code: { $ref: "#/components/schemas/ErrorCode" }
message:
type: string
description: For developers (English).
display_message:
type: string
description: Romanian text an app may show its user.
details:
type: [object, "null"]
additionalProperties: true
Page:
type: object
required: [data, next_cursor, has_more]
properties:
data: { type: array, items: {} }
next_cursor:
type: [string, "null"]
description: Pass it back as `cursor` for the next page; `null` on the last page.
has_more: { type: boolean }
Tombstone:
type: object
description: A row deleted in REVO (lists with `updated_since` or `include_deleted=true`). Remove it from your copy.
required: [id, deleted, deleted_at, updated_at]
properties:
id: { type: string }
deleted: { type: boolean, const: true }
deleted_at: { $ref: "#/components/schemas/NullableTimestamp" }
updated_at: { $ref: "#/components/schemas/NullableTimestamp" }
Me:
type: object
required: [app, business, scopes, location_ids, all_locations, rate_limits, webhook]
properties:
app:
type: object
required: [id, name, mode]
properties:
id: { type: string }
name: { type: string, description: The key's name in REVO. }
mode: { type: string, enum: [test, live] }
business:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
scopes:
type: array
items: { $ref: "#/components/schemas/Scope" }
location_ids:
type: array
description: The active locations this key may use.
items: { type: string }
all_locations:
type: boolean
description: "`true` = every location, including ones added later."
rate_limits:
type: object
required: [per_minute, business_per_minute, heavy_per_minute, orders_per_minute]
properties:
per_minute: { type: integer }
business_per_minute: { type: integer }
heavy_per_minute: { type: integer }
orders_per_minute: { type: integer }
webhook:
type: object
required: [url, configured]
properties:
url: { type: [string, "null"] }
configured: { type: boolean }
OpeningDay:
type: object
required: [open, close, closed]
properties:
open: { type: [string, "null"], examples: ["10:00"] }
close:
type: [string, "null"]
description: Local time; earlier than `open` means after midnight.
examples: ["23:00"]
closed: { type: boolean }
OpeningHours:
type: [object, "null"]
description: "`null` when the restaurant has not set a schedule. Times are local (Europe/Bucharest)."
properties:
monday: { $ref: "#/components/schemas/OpeningDay" }
tuesday: { $ref: "#/components/schemas/OpeningDay" }
wednesday: { $ref: "#/components/schemas/OpeningDay" }
thursday: { $ref: "#/components/schemas/OpeningDay" }
friday: { $ref: "#/components/schemas/OpeningDay" }
saturday: { $ref: "#/components/schemas/OpeningDay" }
sunday: { $ref: "#/components/schemas/OpeningDay" }
exceptions:
type: array
items:
type: object
required: [date, closed]
properties:
date: { type: string, format: date }
closed: { type: boolean }
reason: { type: [string, "null"] }
OrderType:
type: string
enum: [pickup, delivery]
description: "`pickup` — the customer collects it; `delivery` — the restaurant's own courier."
Location:
type: object
required: [id, name, address, city, phone, timezone, ordering, opening_hours]
properties:
id: { type: string }
name: { type: string }
address: { type: [string, "null"] }
city: { type: [string, "null"] }
phone: { type: [string, "null"] }
timezone: { type: string, const: Europe/Bucharest }
ordering:
type: object
description: Online ordering (Comenzi online) at this location, right now.
required: [accepting, status, message, paused_until, next_opening_at, types, default_prep_minutes, answer_within_minutes]
properties:
accepting:
type: boolean
description: The single answer to "can I place an order right now?".
status:
type: string
enum: [open, paused, closed, disabled]
message:
type: [string, "null"]
description: Short Romanian text you may show the customer (e.g. "Revenim la 18:00").
paused_until: { $ref: "#/components/schemas/NullableTimestamp" }
next_opening_at: { $ref: "#/components/schemas/NullableTimestamp" }
types:
type: array
items: { $ref: "#/components/schemas/OrderType" }
default_prep_minutes: { type: integer }
answer_within_minutes: { type: integer }
opening_hours: { $ref: "#/components/schemas/OpeningHours" }
Allergen:
type: object
required: [code, name]
properties:
code: { type: string, description: Stable code from the restaurant's catalogue. }
name: { type: string, description: Romanian name. }
Category:
type: object
required: [id, deleted, location_id, name, image_url, position, created_at, updated_at]
properties:
id: { type: string }
deleted: { type: boolean, const: false }
location_id:
type: [string, "null"]
description: "`null` = shared by every location."
name: { type: string }
image_url: { type: [string, "null"], format: uri }
position: { type: integer }
created_at: { $ref: "#/components/schemas/NullableTimestamp" }
updated_at: { $ref: "#/components/schemas/NullableTimestamp" }
ChannelPrice:
type: object
required: [enabled, price]
properties:
enabled: { type: boolean, description: The product is offered on this channel. }
price: { $ref: "#/components/schemas/Money" }
ProductVariant:
type: object
required: [id, name, price, price_delta]
properties:
id:
type: string
description: Deterministic (`v_…`); the same id the ordering menu uses. Changes when the variant is renamed.
examples: ["v_8e0f…_1a2b3c4d5e"]
name: { type: string }
price:
$ref: "#/components/schemas/Money"
description: Absolute POS price of one unit with this variant.
price_delta:
$ref: "#/components/schemas/Money"
description: Difference from the product price (may be negative).
ProductOption:
type: object
required: [id, name, price]
properties:
id: { type: string, examples: ["x_8e0f…_aa11bb22cc"] }
name: { type: string }
price:
$ref: "#/components/schemas/Money"
description: Added per unit of the product.
ProductExtraGroup:
type: object
required: [id, name, min, max, required, options]
properties:
id: { type: string, examples: ["g_8e0f…_extra"] }
name: { type: string }
min: { type: integer, minimum: 0 }
max:
type: [integer, "null"]
description: "`null` = no limit."
required: { type: boolean }
options:
type: array
items: { $ref: "#/components/schemas/ProductOption" }
Product:
type: object
required: [id, deleted, location_id, category_id, name, description, image_url, price, unit, vat_rate, deposit, available, allergens, diets, calories, prep_minutes, station, channels, variants, extra_groups, position, created_at, updated_at]
properties:
id: { type: string }
deleted: { type: boolean, const: false }
location_id: { type: string }
category_id: { type: [string, "null"] }
name: { type: string, description: The restaurant's own text (emoji kept). }
description: { type: [string, "null"] }
image_url: { type: [string, "null"], format: uri }
price:
$ref: "#/components/schemas/Money"
description: The POS price (VAT included); per kg when `unit` is `kg`.
unit:
type: string
enum: [piece, kg]
description: "`kg` = sold by weight at the till; `price` is per kilogram."
vat_rate:
type: [number, "null"]
description: Percent; VAT is included in the prices.
deposit:
oneOf:
- $ref: "#/components/schemas/Money"
- type: "null"
description: Deposit-return (SGR) charge per unit, e.g. "0.50".
available:
type: boolean
description: "`false` = sold out right now (86)."
allergens:
type: array
items: { $ref: "#/components/schemas/Allergen" }
diets:
type: array
items: { type: string }
calories: { type: [integer, "null"], description: "`null` = not set." }
prep_minutes: { type: [integer, "null"] }
station:
type: string
description: Where it is prepared (`kitchen`, `bar`, or a station the restaurant created).
channels:
type: object
required: [online, glovo, wolt]
properties:
online: { $ref: "#/components/schemas/ChannelPrice" }
glovo: { $ref: "#/components/schemas/ChannelPrice" }
wolt: { $ref: "#/components/schemas/ChannelPrice" }
variants:
type: array
description: When not empty, one variant is chosen per unit sold.
items: { $ref: "#/components/schemas/ProductVariant" }
extra_groups:
type: array
items: { $ref: "#/components/schemas/ProductExtraGroup" }
position: { type: integer }
created_at: { $ref: "#/components/schemas/NullableTimestamp" }
updated_at: { $ref: "#/components/schemas/NullableTimestamp" }
SalesBreakdown:
type: object
required: [orders, net_sales, items, discounts, delivery_fees, tips, deposits, refunds, payments, by_type, by_channel]
properties:
orders: { type: integer, description: Paid orders. }
net_sales:
$ref: "#/components/schemas/Money"
description: "„Vânzări\": total − tip − deposit = items − discounts + delivery_fees."
items: { $ref: "#/components/schemas/Money", description: "Line totals (after line discounts)." }
discounts: { $ref: "#/components/schemas/Money", description: Order-level discounts. }
delivery_fees: { $ref: "#/components/schemas/Money" }
tips: { $ref: "#/components/schemas/Money", description: Not part of net_sales. }
deposits: { $ref: "#/components/schemas/Money", description: SGR deposits; not part of net_sales. }
refunds: { $ref: "#/components/schemas/Money", description: Refunds made that day; not subtracted from net_sales. }
payments:
type: object
description: Payments of the day's orders per method (they include tips and deposits).
required: [cash, card, card_at_counter, online, voucher, protocol, other]
properties:
cash: { $ref: "#/components/schemas/Money" }
card: { $ref: "#/components/schemas/Money" }
card_at_counter: { $ref: "#/components/schemas/Money" }
online: { $ref: "#/components/schemas/Money" }
voucher: { $ref: "#/components/schemas/Money" }
protocol: { $ref: "#/components/schemas/Money", description: "On the house (protocol)." }
other: { $ref: "#/components/schemas/Money" }
by_type:
type: object
description: net_sales per order type.
required: [dine_in, takeaway, delivery]
properties:
dine_in: { $ref: "#/components/schemas/Money" }
takeaway: { $ref: "#/components/schemas/Money" }
delivery: { $ref: "#/components/schemas/Money" }
by_channel:
type: object
description: net_sales per channel.
required: [pos, glovo, wolt, direct]
properties:
pos: { $ref: "#/components/schemas/Money" }
glovo: { $ref: "#/components/schemas/Money" }
wolt: { $ref: "#/components/schemas/Money" }
direct: { $ref: "#/components/schemas/Money", description: Comenzi online. }
SalesDay:
allOf:
- type: object
required: [date]
properties:
date: { type: string, format: date }
- $ref: "#/components/schemas/SalesBreakdown"
DailySales:
type: object
required: [currency, from, to, data, totals]
properties:
currency: { type: string, const: RON }
from: { type: string, format: date }
to: { type: string, format: date }
data:
type: array
items: { $ref: "#/components/schemas/SalesDay" }
totals: { $ref: "#/components/schemas/SalesBreakdown" }
ProductSalesRow:
type: object
required: [product_id, name, category_id, quantity, weight_kg, gross, line_discounts, net]
properties:
date: { type: string, format: date, description: "With `group_by=day`." }
product_id:
type: [string, "null"]
description: "`null` = lines without a catalogue product, grouped by their `name`."
name:
type: string
description: The name on the order lines — for a product, the name it was last sold under in the period (or day).
category_id: { type: [string, "null"] }
quantity: { $ref: "#/components/schemas/Quantity" }
weight_kg:
oneOf:
- $ref: "#/components/schemas/Quantity"
- type: "null"
description: Total weight sold, for products sold by weight.
gross: { $ref: "#/components/schemas/Money" }
line_discounts: { $ref: "#/components/schemas/Money" }
net: { $ref: "#/components/schemas/Money" }
ProductSales:
type: object
required: [currency, from, to, group_by, data]
properties:
currency: { type: string, const: RON }
from: { type: string, format: date }
to: { type: string, format: date }
group_by: { type: string, enum: [period, day] }
data:
type: array
items: { $ref: "#/components/schemas/ProductSalesRow" }
SalesOrderItem:
type: object
required: [id, product_id, name, variant_id, variant_name, quantity, weight_kg, price_per_kg, unit_price, extras, discount_percent, vat_rate, total]
properties:
id: { type: string }
product_id: { type: [string, "null"] }
name: { type: string }
variant_id: { type: [string, "null"], description: "The catalogue's `v_…` id of the variant." }
variant_name: { type: [string, "null"] }
quantity: { $ref: "#/components/schemas/Quantity" }
weight_kg:
oneOf:
- $ref: "#/components/schemas/Quantity"
- type: "null"
price_per_kg:
oneOf:
- $ref: "#/components/schemas/Money"
- type: "null"
unit_price: { $ref: "#/components/schemas/Money" }
extras:
type: array
items:
type: object
required: [name, price]
properties:
name: { type: string }
price: { $ref: "#/components/schemas/Money", description: Per unit. }
discount_percent: { type: number }
vat_rate: { type: [number, "null"] }
total:
$ref: "#/components/schemas/Money"
description: quantity × unit_price × (1 − discount) + extras × quantity.
SalesOrder:
type: object
required: [id, deleted, number, location_id, channel, type, table_id, business_day, created_at, paid_at, updated_at, items, totals, payments, refunds]
properties:
id: { type: string }
deleted: { type: boolean, const: false }
number: { type: [string, "null"], description: "REVO's order number (on receipts)." }
location_id: { type: string }
channel: { type: string, enum: [pos, glovo, wolt, direct] }
type: { type: string, enum: [dine_in, takeaway, delivery] }
table_id: { type: [string, "null"] }
business_day:
type: string
format: date
description: The day the order counts on (Bucharest day of `created_at`).
created_at: { $ref: "#/components/schemas/Timestamp" }
paid_at: { $ref: "#/components/schemas/NullableTimestamp" }
updated_at: { $ref: "#/components/schemas/Timestamp" }
items:
type: array
items: { $ref: "#/components/schemas/SalesOrderItem" }
totals:
type: object
required: [items, discount, delivery_fee, tip, deposit, total, net_sales]
properties:
items: { $ref: "#/components/schemas/Money" }
discount: { $ref: "#/components/schemas/Money" }
delivery_fee: { $ref: "#/components/schemas/Money" }
tip: { $ref: "#/components/schemas/Money" }
deposit: { $ref: "#/components/schemas/Money" }
total: { $ref: "#/components/schemas/Money" }
net_sales: { $ref: "#/components/schemas/Money" }
payments:
type: array
items:
type: object
required: [method, amount, at]
properties:
method: { type: string, enum: [cash, card, card_at_counter, online, voucher, protocol, other] }
amount: { $ref: "#/components/schemas/Money" }
at: { $ref: "#/components/schemas/NullableTimestamp" }
refunds:
type: array
items:
type: object
required: [amount, method, at, item_id]
properties:
amount: { $ref: "#/components/schemas/Money" }
method: { type: string }
at: { $ref: "#/components/schemas/NullableTimestamp" }
item_id: { type: [string, "null"] }
SalesOrderTombstone:
allOf:
- $ref: "#/components/schemas/Tombstone"
- type: object
required: [reason]
properties:
reason:
type: string
enum: [deleted, cancelled, unpaid]
Ingredient:
type: object
required: [id, deleted, location_id, name, unit, stock, min_stock, below_min, category, warehouse, sku, barcode, is_prepared, allergens, created_at, updated_at]
properties:
id: { type: string }
deleted: { type: boolean, const: false }
location_id: { type: string }
name: { type: string }
unit:
type: string
description: The stock unit (`kg`, `g`, `l`, `ml`, `buc`, `portie`).
stock:
$ref: "#/components/schemas/Quantity"
description: Current shelf stock, in `unit`. May be negative (sold before a reception was booked).
min_stock: { $ref: "#/components/schemas/Quantity" }
below_min:
type: boolean
description: |
`stock` ≤ `min_stock` — exactly REVO's „sub minim" (the stock list, the stock reports). With no
minimum set (`0`), an ingredient counts once it reaches 0 or goes negative.
category:
type: [object, "null"]
properties:
id: { type: string }
name: { type: string }
warehouse:
type: [object, "null"]
description: The stock ledger (gestiune) it is kept in.
properties:
id: { type: string }
name: { type: string }
sku: { type: [string, "null"] }
barcode: { type: [string, "null"] }
is_prepared:
type: boolean
description: Made in-house from other ingredients.
allergens:
type: array
items: { $ref: "#/components/schemas/Allergen" }
created_at: { $ref: "#/components/schemas/NullableTimestamp" }
updated_at: { $ref: "#/components/schemas/NullableTimestamp" }
Variant:
type: object
required: [id, name, price]
properties:
id: { type: string, examples: ["v_8e0f…_1a2b3c4d5e"] }
name: { type: string }
price:
$ref: "#/components/schemas/Money"
description: Absolute price of one unit with this variant (not a difference).
ExtraOption:
type: object
required: [id, name, price]
properties:
id: { type: string, examples: ["x_8e0f…_aa11bb22cc"] }
name: { type: string }
price:
$ref: "#/components/schemas/Money"
description: Added per unit of the product.
ExtraGroup:
type: object
required: [id, name, min, max, required, options]
properties:
id: { type: string, examples: ["g_8e0f…_extra"] }
name: { type: string }
min: { type: integer, minimum: 0 }
max:
type: [integer, "null"]
description: "`null` = no limit."
required:
type: boolean
description: At least one option must be picked.
options:
type: array
items: { $ref: "#/components/schemas/ExtraOption" }
MenuProduct:
type: object
required: [id, name, description, image_url, price, vat_rate, available, allergens, diets, calories, prep_minutes, deposit, variants, extra_groups, position]
properties:
id: { type: string }
name: { type: string }
description: { type: [string, "null"] }
image_url: { type: [string, "null"], format: uri }
price:
$ref: "#/components/schemas/Money"
description: One unit without extras; with variants, the cheapest variant ("de la …").
vat_rate:
type: [number, "null"]
description: Informative, percent; VAT is included in `price`.
available:
type: boolean
description: "`false` = temporarily sold out; show it greyed out; ordering it fails."
allergens:
type: array
items: { $ref: "#/components/schemas/Allergen" }
diets:
type: array
items: { type: string }
calories:
type: [integer, "null"]
description: "`0` = not set (this menu has always sent an integer; `GET /products` says `null` instead)."
prep_minutes: { type: [integer, "null"] }
deposit:
oneOf:
- $ref: "#/components/schemas/Money"
- type: "null"
description: Deposit-return (SGR) charge per unit, e.g. "0.50", added to the total.
variants:
type: array
description: When not empty the customer must choose one (send `variant_id`).
items: { $ref: "#/components/schemas/Variant" }
extra_groups:
type: array
items: { $ref: "#/components/schemas/ExtraGroup" }
position: { type: integer }
MenuCategory:
type: object
required: [id, name, image_url, position, products]
properties:
id: { type: string }
name: { type: string }
image_url: { type: [string, "null"], format: uri }
position: { type: integer }
products:
type: array
items: { $ref: "#/components/schemas/MenuProduct" }
Menu:
type: object
required: [location_id, currency, updated_at, categories]
properties:
location_id: { type: string }
currency: { type: string, const: RON }
updated_at: { $ref: "#/components/schemas/NullableTimestamp" }
categories:
type: array
items: { $ref: "#/components/schemas/MenuCategory" }
Customer:
type: object
required: [name, phone]
properties:
name: { type: string, minLength: 1, maxLength: 120 }
phone:
type: string
minLength: 6
maxLength: 40
description: Prefer `+40…`. The staff may call it.
email: { type: [string, "null"], maxLength: 190 }
Delivery:
type: object
required: [address]
properties:
address: { type: string, maxLength: 255 }
city: { type: [string, "null"] }
notes: { type: [string, "null"], maxLength: 255 }
lat: { type: [number, "null"] }
lng: { type: [number, "null"] }
OrderItemRequest:
type: object
required: [product_id, quantity]
properties:
product_id: { type: string, description: From the menu. }
variant_id:
type: [string, "null"]
description: Required when the product has variants.
quantity: { type: integer, minimum: 1, maximum: 99 }
options:
type: array
items:
type: object
required: [id]
properties:
id: { type: string, description: From the product's `extra_groups`. }
quantity:
type: integer
minimum: 1
maximum: 10
description: Per unit of the product (default 1).
notes: { type: [string, "null"], maxLength: 200, description: Shown to the kitchen. }
PaymentRequest:
type: object
required: [status]
properties:
status:
type: string
enum: [paid, unpaid]
description: "`paid` — you already charged the customer online; `unpaid` — paid at pickup or to the courier."
method: { type: [string, "null"], description: "Free text for the staff, e.g. `card`, `apple_pay`, `cash`." }
reference: { type: [string, "null"], maxLength: 100, description: Your payment/transaction id. }
OrderRequest:
type: object
required: [external_id, location_id, type, customer, items, payment]
properties:
external_id:
type: string
minLength: 1
maxLength: 64
description: Your order id, unique per key. Makes the call idempotent.
location_id: { type: string, description: From `/locations`. }
type:
$ref: "#/components/schemas/OrderType"
description: Must be in the location's `ordering.types`.
scheduled_for:
type: [string, "null"]
format: date-time
description: "`null` = as soon as possible; otherwise a time today, at least 20 minutes and at most 12 hours ahead, inside opening hours."
customer: { $ref: "#/components/schemas/Customer" }
delivery:
oneOf:
- $ref: "#/components/schemas/Delivery"
- type: "null"
description: Required when `type` = `delivery`.
items:
type: array
minItems: 1
maxItems: 100
items: { $ref: "#/components/schemas/OrderItemRequest" }
notes: { type: [string, "null"], maxLength: 500, description: Note for the restaurant. }
allergies: { type: [string, "null"], maxLength: 255, description: Highlighted in red for the staff. }
cutlery: { type: [boolean, "null"] }
tip:
type: [string, "null"]
pattern: "^\\d+(\\.\\d{1,2})?$"
description: Decimal string ≥ "0.00". Included in the total; goes to the staff.
payment: { $ref: "#/components/schemas/PaymentRequest" }
expected_total:
type: [string, "null"]
pattern: "^\\d+(\\.\\d{1,2})?$"
description: Strongly recommended. The total your app showed; a different REVO total → `409 price_changed`.
OrderItemOption:
type: object
required: [id, name, quantity, unit_price]
properties:
id: { type: string }
name: { type: string }
quantity: { type: integer }
unit_price: { $ref: "#/components/schemas/Money" }
OrderItem:
type: object
required: [product_id, variant_id, name, variant_name, quantity, unit_price, options, notes, deposit, total]
properties:
product_id: { type: string }
variant_id: { type: [string, "null"] }
name: { type: string }
variant_name: { type: [string, "null"] }
quantity: { type: integer }
unit_price: { $ref: "#/components/schemas/Money" }
options:
type: array
items: { $ref: "#/components/schemas/OrderItemOption" }
notes: { type: [string, "null"] }
deposit:
$ref: "#/components/schemas/Money"
description: quantity × unit deposit.
total:
$ref: "#/components/schemas/Money"
description: quantity × (unit_price + Σ option.unit_price × option.quantity); deposit excluded.
OrderStatus:
type: string
enum: [received, accepted, ready, completed, rejected, cancelled]
description: |
`received` → `accepted` → `ready` → `completed`; `received` → `rejected`; `received` → `cancelled`;
`accepted`/`ready` → `cancelled`. `rejected`, `cancelled` and `completed` are final.
Order:
type: object
required: [id, external_id, number, pickup_code, status, test, type, location_id, scheduled_for, placed_at, answer_by, accepted_at, estimated_ready_at, ready_at, completed_at, rejected_at, cancelled_at, rejection, cancellation, customer, delivery, items, notes, allergies, cutlery, totals, payment, created_at, updated_at]
properties:
id: { type: string }
external_id: { type: string }
number:
type: [string, "null"]
description: REVO's order number (on receipts); `null` for test orders.
examples: ["20261005-042"]
pickup_code:
type: [string, "null"]
description: "Show it to the customer (\"Cod ridicare: 4821\"); staff see the same code."
status: { $ref: "#/components/schemas/OrderStatus" }
test: { type: boolean }
type: { $ref: "#/components/schemas/OrderType" }
location_id: { type: string }
scheduled_for: { $ref: "#/components/schemas/NullableTimestamp" }
placed_at: { $ref: "#/components/schemas/NullableTimestamp" }
answer_by:
$ref: "#/components/schemas/NullableTimestamp"
description: The restaurant must accept before this time, otherwise the order is rejected with `no_response`.
accepted_at: { $ref: "#/components/schemas/NullableTimestamp" }
estimated_ready_at:
$ref: "#/components/schemas/NullableTimestamp"
description: May move later if the restaurant adds minutes.
ready_at: { $ref: "#/components/schemas/NullableTimestamp" }
completed_at: { $ref: "#/components/schemas/NullableTimestamp" }
rejected_at: { $ref: "#/components/schemas/NullableTimestamp" }
cancelled_at: { $ref: "#/components/schemas/NullableTimestamp" }
rejection:
type: [object, "null"]
properties:
code: { type: string, enum: [items_unavailable, closing_soon, no_response, other] }
message: { type: [string, "null"] }
cancellation:
type: [object, "null"]
properties:
by: { type: string, enum: [customer, restaurant] }
reason: { type: [string, "null"] }
customer: { $ref: "#/components/schemas/Customer" }
delivery:
oneOf:
- $ref: "#/components/schemas/Delivery"
- type: "null"
items:
type: array
items: { $ref: "#/components/schemas/OrderItem" }
notes: { type: [string, "null"] }
allergies: { type: [string, "null"] }
cutlery: { type: [boolean, "null"] }
totals:
type: object
required: [items, deposit, tip, total]
properties:
items: { $ref: "#/components/schemas/Money" }
deposit: { $ref: "#/components/schemas/Money" }
tip: { $ref: "#/components/schemas/Money" }
total:
$ref: "#/components/schemas/Money"
description: items + deposit + tip.
payment:
type: object
required: [status, method, reference]
properties:
status:
type: string
enum: [paid, unpaid]
description: An `unpaid` order becomes `paid` once paid at the counter or to the courier.
method: { type: [string, "null"] }
reference: { type: [string, "null"] }
created_at: { $ref: "#/components/schemas/Timestamp" }
updated_at: { $ref: "#/components/schemas/Timestamp" }
WebhookTestResult:
type: object
required: [ok, http_status, error, event_id]
properties:
ok:
type: boolean
description: Your endpoint answered 2xx within 10 s.
http_status: { type: [integer, "null"] }
error:
type: [string, "null"]
description: Why it failed (Romanian, as the restaurant sees it in "Activitate").
event_id: { type: [string, "null"] }
WebhookEventType:
type: string
enum: [order.accepted, order.rejected, order.ready, order.completed, order.cancelled, ping]
WebhookEvent:
type: object
required: [id, type, created_at, test, data]
properties:
id:
type: string
description: "`evt_` + ULID; deduplicate on it."
examples: ["evt_01J9Z6M4X2K8Q7B3R5T1V0W9YZ"]
type: { $ref: "#/components/schemas/WebhookEventType" }
created_at: { $ref: "#/components/schemas/Timestamp" }
test:
type: boolean
description: "`true` for test orders and for a test key's `ping`."
data:
type: object
description: "`{order}` for `order.*` events (the full order object, as `GET /orders/{id}`); `{app, message}` for `ping`."
properties:
order: { $ref: "#/components/schemas/Order" }
app:
type: object
properties:
id: { type: string }
name: { type: string }
mode: { type: string, enum: [test, live] }
message: { type: string }