For developers of a restaurant's own website or mobile app (for example a restaurant's branded app) who want to show the restaurant's live menu, place orders that ring on the restaurant's REVO POS, and follow each order until the customer has it.
- Base URL:
https://api.revoapp.ro/api/public/v1 - Format: JSON (UTF-8). Send
Content-Type: application/jsonandAccept: application/json. - Money: decimal strings in RON with exactly two decimals, e.g.
"24.50". Currency is alwaysRON. - Time: ISO 8601 in UTC, e.g.
"2026-10-05T16:30:00Z". Restaurants operate inEurope/Bucharest. - Ids: opaque strings. Store them as strings; never parse them.
- Version: v1. We only add fields to v1 (never remove or rename); ignore fields you don't know.
1. How it works
your app ──POST /orders──▶ REVO ──rings──▶ restaurant POS (sound + "Comandă nouă" dialog)
│ staff accept (with a ready time) or reject
your app ◀──webhook / GET /orders/{id}────────┤
▼
kitchen (KDS / printer) → "Gata" → handed over- Your app reads the restaurant's locations and the menu of the chosen location.
- The customer builds a cart; your app sends
POST /orders. REVO checks availability and opening hours, computes the prices itself, and answers201with the order in statusreceived. - The order rings on the restaurant's POS until a staff member accepts it (choosing a ready time) or rejects it. If nobody answers in time (10 minutes by default, set by the restaurant), REVO rejects it automatically with reason
no_response, so the customer is never left waiting. - After acceptance the order goes to the kitchen. Staff mark it ready, then completed when it is handed to the customer or the courier.
- You learn about every change by webhook (recommended) or by polling
GET /orders/{id}.
2. Authentication and keys
Every request carries the app's API key:
Authorization: Bearer rvo_live_7Kq2vX…- The restaurant creates the key in REVO → Setări → Comenzi online → Conectează o aplicație (or in Setări → Chei API with „Comenzi online” ticked). The key is shown once; if it is lost, the restaurant generates a new one. The old key then stops at once, or keeps working for 24 hours if the restaurant chooses so, while you deploy the new one.
- An ordering key holds the scope
orders:write. The same key may also hold read scopes (menu, locations, sales, stock): see Getting started. - One key belongs to one app ("Aplicația Bistro", "Site bistro.ro") of one restaurant business. It can be limited to some of the restaurant's locations.
- Two kinds of keys:
rvo_test_…— test mode (section 3).rvo_live_…— real orders.
- Keep the key on your server. Call REVO from your backend, not directly from the mobile app: a key inside an app binary can be extracted and used to place orders. If you must call from the device, use a dedicated key and ask the restaurant to watch "Activitate" in REVO; it can disable the key instantly.
- Call it from a server, not from a browser. CORS only admits REVO's own pages (the interactive reference); a website calls REVO from its backend, so the key is never published.
- Missing or wrong key →
401 unauthorized. Disabled key →403 api_key_disabled. A key withoutorders:write→403 insufficient_scope. Online ordering switched off by the restaurant →403 api_disabled.
Check a key with GET /me:
GET /api/public/v1/me
Authorization: Bearer rvo_test_…{
"app": { "id": "4f6c…", "name": "Aplicația Bistro", "mode": "test" },
"business": { "id": "019f8012-…", "name": "Bistro" },
"scopes": ["orders:write", "locations:read"],
"location_ids": ["9a1b…"],
"all_locations": true,
"rate_limits": { "per_minute": 120, "business_per_minute": 600, "heavy_per_minute": 30, "orders_per_minute": 30 },
"webhook": { "url": "https://api.bistro.ro/revo/webhook", "configured": true }
}3. Test mode
Use a rvo_test_ key while you build and whenever you want to test against the real restaurant safely.
- Test orders go through exactly the same validation, pricing, statuses and webhooks as live ones.
- On the POS they carry a TEST badge and ring **only on devices where the restaurant switched on "Primește comenzile de test"**, so they don't disturb the staff during service.
- They never reach the kitchen screen or printers, the fiscal printer, stock, reports or loyalty.
- Staff (or you, with the restaurant) accept / reject / mark ready / complete them on that device, which fires the same webhooks with
"test": true. order.testistruefor them;order.numberisnull(no real REVO order number is used).
When you go live, switch to the rvo_live_ key. Nothing else changes.
4. Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /me | Check the key; which app, business and locations it serves. |
| GET | /locations | Locations this key can order at, with live ordering status and opening hours. |
| GET | /locations/{location_id}/menu | Full orderable menu of a location. Supports ETag / If-None-Match. |
| POST | /orders | Place an order. |
| GET | /orders/{order_id} | One order with its current status. |
| GET | /orders | Look up orders: ?external_id= or ?updated_since= (reconciliation). |
| POST | /orders/{order_id}/cancel | Cancel an order before the restaurant accepts it. |
| POST | /webhooks/test | Send a ping event to the webhook URL configured for this key. |
These need the orders:write scope. /locations also answers keys with locations:read. The read endpoints (catalogue, sales, stock) are in the interactive reference.
5. Locations
GET /api/public/v1/locations{
"data": [
{
"id": "9a1b3c…",
"name": "Bistro Pitești",
"address": "Str. Victoriei 10",
"city": "Pitești",
"phone": "+40 348 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": "10:00", "close": "01:00", "closed": false },
"sunday": { "open": "12:00", "close": "22:00", "closed": false },
"exceptions": [ { "date": "2026-12-25", "closed": true, "reason": "Crăciun" } ]
}
}
]
}ordering.acceptingis the single answer to "can I place an order right now?".ordering.status:open— accepting orders.paused— the restaurant paused online orders (busy);paused_untilsays until when.closed— outside opening hours;next_opening_atsays when it opens.disabled— this location does not take online orders.
ordering.message: a short Romanian text you may show the customer (e.g. "Revenim la 18:00").opening_hoursisnullwhen the restaurant has not set a schedule (then it is considered open unless paused). Times are local (Europe/Bucharest); acloseearlier thanopenmeans after midnight.answer_within_minutes: how long the restaurant has to accept before the order is rejected automatically.
6. Menu
GET /api/public/v1/locations/9a1b3c…/menu
If-None-Match: "m-5f2a9c"{
"location_id": "9a1b3c…",
"currency": "RON",
"updated_at": "2026-10-05T09:12:44Z",
"categories": [
{
"id": "c2d4…",
"name": "Pizza",
"image_url": null,
"position": 1,
"products": [
{
"id": "8e0f…",
"name": "Margherita",
"description": "Sos de roșii, mozzarella, busuioc",
"image_url": "https://api.revoapp.ro/api/uploads/3b9e…/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_8e0f…_1a2b3c4d5e", "name": "Mică (30 cm)", "price": "32.00" },
{ "id": "v_8e0f…_9f8e7d6c5b", "name": "Mare (40 cm)", "price": "41.00" }
],
"extra_groups": [
{
"id": "g_8e0f…_extra",
"name": "Extra",
"min": 0,
"max": 3,
"required": false,
"options": [
{ "id": "x_8e0f…_aa11bb22cc", "name": "Mozzarella extra", "price": "5.00" },
{ "id": "x_8e0f…_dd33ee44ff", "name": "Ciuperci", "price": "4.00" }
]
}
],
"position": 3
}
]
}
]
}Rules:
- Only products the restaurant offers online are listed.
available: falsemeans temporarily sold out ("86"): show it greyed out; ordering it fails. - Products the restaurant sells by weight (priced per kg, weighed at the counter) are never listed: they can only be bought in the restaurant. An order naming one is refused with
422 validation_failed. priceis the price of one unit without extras. When a product hasvariants, the customer must choose one: the line then costs the variant'sprice(absolute, not a difference), andpriceon the product is the cheapest variant (show "de la 32,00 lei").extra_groups:min/maxare the number of options the customer may pick from that group (max: nullmeans no limit);required: truemeans at least one. Each option adds itspriceper unit of the product.depositis the Romanian deposit-return (SGR) charge per unit, e.g."0.50", ornull. REVO adds it to the total for pickup and delivery orders.allergensuse the restaurant's catalogue: stablecode, Romanianname.vat_rateis informative (percent, VAT included inprice).caloriesis an integer;0means the restaurant did not fill it in.- Variant and option ids change when the restaurant renames them. Cache the menu, but refresh it at least every 15 minutes and right before checkout (use
If-None-Match; an unchanged menu answers304).
7. Placing an order
POST /api/public/v1/orders
Authorization: Bearer rvo_live_…
Content-Type: application/json{
"external_id": "BIS-10234",
"location_id": "9a1b3c…",
"type": "pickup",
"scheduled_for": null,
"customer": { "name": "Ana Pop", "phone": "+40722123456", "email": "ana@example.com" },
"delivery": null,
"items": [
{
"product_id": "8e0f…",
"variant_id": "v_8e0f…_9f8e7d6c5b",
"quantity": 2,
"options": [ { "id": "x_8e0f…_aa11bb22cc", "quantity": 1 } ],
"notes": "fără busuioc"
},
{ "product_id": "5c7d…", "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"
}Fields:
| Field | Required | Notes |
|---|---|---|
external_id | yes | Your order id, 1–64 chars, unique per app. It makes the call idempotent (see below). |
location_id | yes | From /locations. |
type | yes | pickup (the customer collects it) or delivery (the restaurant's own courier). Must be in the location's ordering.types. |
scheduled_for | no | null = as soon as possible. Otherwise a time today, at least 20 minutes ahead, at most 12 hours ahead, inside opening hours. The kitchen sees the time on the ticket. |
customer.name | yes | 1–120 chars. Shown to the staff. |
customer.phone | yes | 6–40 chars; prefer +40…. The staff may call it. |
customer.email | no | ≤ 190 chars. |
delivery | when type = delivery | { "address": "…" (required, ≤ 255), "city": "…", "notes": "…" (≤ 255), "lat": 44.85, "lng": 24.87 }. |
items | yes | 1–100 lines. |
items[].product_id | yes | From the menu. |
items[].variant_id | when the product has variants | From the product's variants. |
items[].quantity | yes | Integer 1–99. |
items[].options | no | [{ "id", "quantity" }] from the product's extra_groups; option quantity 1–10 per unit of the product. |
items[].notes | no | ≤ 200 chars, shown to the kitchen. |
notes | no | ≤ 500 chars, note for the restaurant. |
allergies | no | ≤ 255 chars; highlighted in red for the staff. |
cutlery | no | true / false. |
tip | no | Decimal string ≥ "0.00". Included in the total; goes to the staff. |
payment.status | yes | paid — you already charged the customer online. unpaid — the customer pays at pickup or to the courier. |
payment.method | no | Free text for the staff, e.g. card, apple_pay, cash. |
payment.reference | no | Your payment/transaction id (≤ 100), shown on the order. |
expected_total | strongly recommended | The total your app showed the customer. If REVO's total differs, the order is refused with 409 price_changed (nothing is created). |
Delivery fees and packaging: v1 has no separate fee field. Ask the restaurant to create a product such as "Taxă de livrare" or "Ambalaj" (it then appears in the menu) and add it as a normal line.
Discounts, vouchers and loyalty points are not part of v1. REVO links the order to the customer (by phone) so the restaurant's own loyalty program counts it once the order is paid.
Response — 201 Created
{
"id": "0b4e6f1c-…",
"external_id": "BIS-10234",
"number": "20261005-042",
"pickup_code": "4821",
"status": "received",
"test": false,
"type": "pickup",
"location_id": "9a1b3c…",
"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": [
{
"product_id": "8e0f…",
"variant_id": "v_8e0f…_9f8e7d6c5b",
"name": "Margherita",
"variant_name": "Mare (40 cm)",
"quantity": 2,
"unit_price": "41.00",
"options": [ { "id": "x_8e0f…_aa11bb22cc", "name": "Mozzarella extra", "quantity": 1, "unit_price": "5.00" } ],
"notes": "fără busuioc",
"deposit": "0.00",
"total": "92.00"
},
{
"product_id": "5c7d…",
"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": { "items": "106.50", "deposit": "0.50", "tip": "5.00", "total": "112.00" },
"payment": { "status": "paid", "method": "card", "reference": "pi_3Nf2…" },
"created_at": "2026-10-05T13:02:11Z",
"updated_at": "2026-10-05T13:02:11Z"
}- Line
total=quantity × (unit_price + Σ option.unit_price × option.quantity); the deposit is reported separately per line (deposit= quantity × unit deposit) and intotals.deposit. totals.total=items+deposit+tip.pickup_code— 4 digits; show it to the customer ("Cod ridicare: 4821"). Staff see the same code.number— REVO's order number (shown on receipts);nullfor test orders.answer_by— the restaurant must accept before this time, otherwise the order is rejected withno_response.
Idempotency — always reuse external_id on retries
Generate external_id once per checkout. If the network fails or you get a 5xx, retry with the same external_id: REVO returns the order it already created (200 OK with header Idempotent-Replayed: true) instead of creating a second one. A different payload with an external_id already used still returns the original order — external_id identifies the order, not the request.
Why an order can be refused (nothing is created)
| HTTP | error.code | When | What to do |
|---|---|---|---|
| 409 | location_unavailable | Closed, paused, online orders off, or type not offered. details.reason = closed | paused | disabled | type_not_offered; plus next_opening_at / paused_until. | Show display_message; refresh /locations. |
| 409 | items_unavailable | A product, variant or option is unknown, not offered online or sold out. details.items = [{ "index", "product_id", "reason" }], reason ∈ not_found, unavailable, variant_required, variant_not_found, option_not_found, options_invalid. | Refresh the menu, update the cart, ask the customer. |
| 409 | price_changed | expected_total ≠ REVO's total. details.expected_total, details.total, details.items (REVO's priced lines). | Show the new total and ask the customer to confirm, then retry with the same external_id and the new expected_total. |
| 422 | validation_failed | Malformed request. details.fields = { "items.0.quantity": ["…"] }. A line naming a product sold by weight lands here too (items.N.product_id). | Fix the request. |
8. Order statuses
status | Meaning | What your app shows |
|---|---|---|
received | Waiting for the restaurant to accept (ringing on the POS). | "Așteptăm confirmarea restaurantului…" |
accepted | Confirmed; the kitchen is preparing it. estimated_ready_at is set. | "Confirmată — gata la 13:25" |
ready | Ready for pickup (or for the courier). | "Comanda ta e gata. Cod: 4821" |
completed | Handed to the customer or the courier. Final. | "Ridicată. Poftă bună!" |
rejected | The restaurant declined it, or did not answer in time. Final. rejection = { "code", "message" }. | rejection.message; refund if paid. |
cancelled | Cancelled by the customer (before acceptance) or by the restaurant (after acceptance). Final. cancellation = { "by": "customer" | "restaurant", "reason" }. | Explain; refund if paid. |
Order of statuses: received → accepted → ready → completed; received → rejected; received → cancelled; accepted/ready → cancelled. rejected, cancelled and completed never change again.
rejection.code: items_unavailable (sold out), closing_soon, no_response (nobody accepted in time), other (see message). estimated_ready_at may move later if the restaurant adds minutes; it is always in the latest order.accepted webhook and in GET /orders/{id}.
Money: REVO never charges or refunds the customer — your payment provider does. If a paid order ends rejected or cancelled, refund it on your side. An unpaid order is paid at the counter or to the courier; then payment.status becomes paid.
9. Following an order
9.1 Webhooks (recommended)
The restaurant enters your webhook URL when it creates the key (or later, in REVO). REVO then POSTs every change:
POST https://api.bistro.ro/revo/webhook
Content-Type: application/json
User-Agent: REVO-Webhooks/1.0
Revo-Event-Id: evt_01J9Z6M4X2K8Q7B3R5T1V0W9YZ
Revo-Event-Type: order.accepted
Revo-Signature: t=1728133351,v1=5f8a0c…{
"id": "evt_01J9Z6M4X2K8Q7B3R5T1V0W9YZ",
"type": "order.accepted",
"created_at": "2026-10-05T13:02:31Z",
"test": false,
"data": { "order": { "id": "0b4e6f1c-…", "external_id": "BIS-10234", "status": "accepted", "…": "the full order object of §7" } }
}Events: order.accepted, order.rejected, order.ready, order.completed, order.cancelled, and ping (from POST /webhooks/test or the "Testează webhook" button in REVO).
- Answer with any
2xxwithin 10 seconds. Do the work asynchronously if it is slow. - 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 whosedata.order.updated_atis older than what you have; final statuses never change. - URL must be
https://and publicly reachable (no private IPs). Redirects are not followed.
Verify the signature with the webhook secret (whsec_…, shown to the restaurant with the key):
signed_payload = "<t>" + "." + <raw request body exactly as received>
expected = hex( HMAC_SHA256(webhook_secret, signed_payload) )
valid = constant_time_equals(expected, v1) AND |now − t| ≤ 300 secondsNode.js:
import crypto from 'node:crypto';
export function verifyRevo(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map(p => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) <= 300;
return fresh && expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}PHP:
function verify_revo(string $rawBody, string $header, string $secret): bool {
parse_str(str_replace(',', '&', $header), $p);
$expected = hash_hmac('sha256', $p['t'].'.'.$rawBody, $secret);
return abs(time() - (int) $p['t']) <= 300 && hash_equals($expected, $p['v1'] ?? '');
}9.2 Polling
GET /orders/{order_id} returns the same order object. 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. Webhooks + an occasional poll is the most robust setup.
9.3 Reconciliation
GET /orders?updated_since=2026-10-05T12:00:00Z&limit=100 returns orders changed since then, oldest first:
{ "data": [ { "…": "order objects" } ], "next_updated_since": "2026-10-05T13:02:31Z" }next_updated_since is null when there is nothing more. GET /orders?external_id=BIS-10234 finds an order by your id.
10. Cancelling
POST /api/public/v1/orders/0b4e6f1c-…/cancel
{ "reason": "Clientul a renunțat" }- Allowed only while the order is
received→200with 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 (/locations→phone).
11. Errors
All errors have the same shape:
{
"error": {
"code": "location_unavailable",
"message": "Location is paused until 2026-10-05T14:00:00Z.",
"display_message": "Restaurantul nu primește comenzi online acum. Revenim la 17:00.",
"details": { "reason": "paused", "paused_until": "2026-10-05T14:00:00Z" }
}
}message is for developers (English). display_message is a Romanian text you may show the customer.
| HTTP | code |
|---|---|
| 400 | bad_request (invalid JSON) |
| 401 | unauthorized |
| 403 | insufficient_scope, api_key_disabled, api_disabled, location_forbidden |
| 404 | not_found |
| 409 | location_unavailable, items_unavailable, price_changed, cannot_cancel, webhook_not_configured |
| 422 | validation_failed |
| 429 | rate_limited (see Retry-After) |
| 500 | server_error (retry with the same external_id) |
| 503 | service_unavailable (temporarily unavailable; retry later with the same external_id) |
12. Limits
- 120 requests per minute per key (600 for all keys of one restaurant together); at most 30
POST /ordersper minute per key. Over the limit →429withRetry-After(seconds). - Every answer to a valid, active key (errors included) carries
X-RateLimit-LimitandX-RateLimit-Remaining.401,403 api_key_disabled,503and a URL that is no endpoint do not. - 30 requests with a missing or wrong key from one address in a minute →
429for the rest of that minute. - Request body ≤ 64 KB; ≤ 100 lines per order.
13. Quick start (curl)
KEY=rvo_test_… # from REVO → Setări → Comenzi online (or Chei API)
API=https://api.revoapp.ro/api/public/v1
curl -s $API/me -H "Authorization: Bearer $KEY"
curl -s $API/locations -H "Authorization: Bearer $KEY"
curl -s $API/locations/<location_id>/menu -H "Authorization: Bearer $KEY"
curl -s -X POST $API/orders -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -d '{
"external_id": "test-0001",
"location_id": "<location_id>",
"type": "pickup",
"customer": { "name": "Test", "phone": "+40700000000" },
"items": [ { "product_id": "<product_id>", "quantity": 1 } ],
"payment": { "status": "unpaid" }
}'
curl -s $API/orders/<order_id> -H "Authorization: Bearer $KEY"Pick a product without variants for this first order. A product whose menu entry has variants needs a "variant_id" on the line, or the order is refused with 409 items_unavailable (reason: variant_required).
With a test key the order rings (TEST badge) on the restaurant devices that receive test orders.
14. Checklist for a good ordering app
- Before checkout:
GET /locations→ ifordering.acceptingisfalse, showordering.messageand don't let the customer pay. - Refresh the menu right before checkout and send
expected_total. - Generate
external_idonce per checkout; retry with the same one. - Charge online after REVO answered
201(or authorize the card first and capture afterorder.accepted; refund or void onrejected/cancelled). - Show the
pickup_codeand the live status; useestimated_ready_atfor the ready time. - Handle
rejected(rejection.message) andcancelledgracefully, with an automatic refund for paid orders. - Verify webhook signatures and deduplicate by event
id.