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 }