REVO · Dezvoltatori

Comenzi online: API-ul de comenzi

Pentru site-ul sau aplicația restaurantului tău: meniul live, comenzi care sună pe POS-ul REVO și se acceptă dintr-o atingere, statusuri trimise înapoi prin webhook. Restaurantul creează cheile în REVO → Setări → Comenzi online sau în Setări → Chei API. Documentația de mai jos e în engleză, pentru echipa care construiește aplicația. Pentru meniu, vânzări și stocuri vezi API pentru dezvoltatori.

https://api.revoapp.ro/api/public/v1

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/json and Accept: application/json.
  • Money: decimal strings in RON with exactly two decimals, e.g. "24.50". Currency is always RON.
  • Time: ISO 8601 in UTC, e.g. "2026-10-05T16:30:00Z". Restaurants operate in Europe/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

text
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
  1. Your app reads the restaurant's locations and the menu of the chosen location.
  2. The customer builds a cart; your app sends POST /orders. REVO checks availability and opening hours, computes the prices itself, and answers 201 with the order in status received.
  3. 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.
  4. After acceptance the order goes to the kitchen. Staff mark it ready, then completed when it is handed to the customer or the courier.
  5. 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:

text
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 without orders:write → 403 insufficient_scope. Online ordering switched off by the restaurant → 403 api_disabled.

Check a key with GET /me:

http
GET /api/public/v1/me
Authorization: Bearer rvo_test_…
json
{
  "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.test is true for them; order.number is null (no real REVO order number is used).

When you go live, switch to the rvo_live_ key. Nothing else changes.

4. Endpoints

MethodPathPurpose
GET/meCheck the key; which app, business and locations it serves.
GET/locationsLocations this key can order at, with live ordering status and opening hours.
GET/locations/{location_id}/menuFull orderable menu of a location. Supports ETag / If-None-Match.
POST/ordersPlace an order.
GET/orders/{order_id}One order with its current status.
GET/ordersLook up orders: ?external_id= or ?updated_since= (reconciliation).
POST/orders/{order_id}/cancelCancel an order before the restaurant accepts it.
POST/webhooks/testSend 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

http
GET /api/public/v1/locations
json
{
  "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.accepting is the single answer to "can I place an order right now?".
  • ordering.status:
    • open — accepting orders.
    • paused — the restaurant paused online orders (busy); paused_until says until when.
    • closed — outside opening hours; next_opening_at says 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_hours is null when the restaurant has not set a schedule (then it is considered open unless paused). Times are local (Europe/Bucharest); a close earlier than open means after midnight.
  • answer_within_minutes: how long the restaurant has to accept before the order is rejected automatically.

6. Menu

http
GET /api/public/v1/locations/9a1b3c…/menu
If-None-Match: "m-5f2a9c"
json
{
  "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: false means 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.
  • price is the price of one unit without extras. When a product has variants, the customer must choose one: the line then costs the variant's price (absolute, not a difference), and price on the product is the cheapest variant (show "de la 32,00 lei").
  • extra_groups: min/max are the number of options the customer may pick from that group (max: null means no limit); required: true means at least one. Each option adds its price per unit of the product.
  • deposit is the Romanian deposit-return (SGR) charge per unit, e.g. "0.50", or null. REVO adds it to the total for pickup and delivery orders.
  • allergens use the restaurant's catalogue: stable code, Romanian name.
  • vat_rate is informative (percent, VAT included in price).
  • calories is an integer; 0 means 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 answers 304).

7. Placing an order

http
POST /api/public/v1/orders
Authorization: Bearer rvo_live_…
Content-Type: application/json
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:

FieldRequiredNotes
external_idyesYour order id, 1–64 chars, unique per app. It makes the call idempotent (see below).
location_idyesFrom /locations.
typeyespickup (the customer collects it) or delivery (the restaurant's own courier). Must be in the location's ordering.types.
scheduled_fornonull = 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.nameyes1–120 chars. Shown to the staff.
customer.phoneyes6–40 chars; prefer +40…. The staff may call it.
customer.emailno≤ 190 chars.
deliverywhen type = delivery{ "address": "…" (required, ≤ 255), "city": "…", "notes": "…" (≤ 255), "lat": 44.85, "lng": 24.87 }.
itemsyes1–100 lines.
items[].product_idyesFrom the menu.
items[].variant_idwhen the product has variantsFrom the product's variants.
items[].quantityyesInteger 1–99.
items[].optionsno[{ "id", "quantity" }] from the product's extra_groups; option quantity 1–10 per unit of the product.
items[].notesno≤ 200 chars, shown to the kitchen.
notesno≤ 500 chars, note for the restaurant.
allergiesno≤ 255 chars; highlighted in red for the staff.
cutlerynotrue / false.
tipnoDecimal string ≥ "0.00". Included in the total; goes to the staff.
payment.statusyespaid — you already charged the customer online. unpaid — the customer pays at pickup or to the courier.
payment.methodnoFree text for the staff, e.g. card, apple_pay, cash.
payment.referencenoYour payment/transaction id (≤ 100), shown on the order.
expected_totalstrongly recommendedThe 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

json
{
  "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 in totals.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); null for test orders.
  • answer_by — the restaurant must accept before this time, otherwise the order is rejected with no_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)

HTTPerror.codeWhenWhat to do
409location_unavailableClosed, 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.
409items_unavailableA 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.
409price_changedexpected_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.
422validation_failedMalformed 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

statusMeaningWhat your app shows
receivedWaiting for the restaurant to accept (ringing on the POS)."Așteptăm confirmarea restaurantului…"
acceptedConfirmed; the kitchen is preparing it. estimated_ready_at is set."Confirmată — gata la 13:25"
readyReady for pickup (or for the courier)."Comanda ta e gata. Cod: 4821"
completedHanded to the customer or the courier. Final."Ridicată. Poftă bună!"
rejectedThe restaurant declined it, or did not answer in time. Final. rejection = { "code", "message" }.rejection.message; refund if paid.
cancelledCancelled 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

The restaurant enters your webhook URL when it creates the key (or later, in REVO). REVO then POSTs every change:

http
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…
json
{
  "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 2xx within 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 whose data.order.updated_at is 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):

text
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 seconds

Node.js:

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:

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:

json
{ "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

http
POST /api/public/v1/orders/0b4e6f1c-…/cancel
{ "reason": "Clientul a renunțat" }
  • 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 (/locations → phone).

11. Errors

All errors have the same shape:

json
{
  "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.

HTTPcode
400bad_request (invalid JSON)
401unauthorized
403insufficient_scope, api_key_disabled, api_disabled, location_forbidden
404not_found
409location_unavailable, items_unavailable, price_changed, cannot_cancel, webhook_not_configured
422validation_failed
429rate_limited (see Retry-After)
500server_error (retry with the same external_id)
503service_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 /orders per minute per key. Over the limit → 429 with Retry-After (seconds).
  • Every answer to a valid, active key (errors included) carries X-RateLimit-Limit and X-RateLimit-Remaining. 401, 403 api_key_disabled, 503 and a URL that is no endpoint do not.
  • 30 requests with a missing or wrong key from one address in a minute → 429 for the rest of that minute.
  • Request body ≤ 64 KB; ≤ 100 lines per order.

13. Quick start (curl)

bash
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 → if ordering.accepting is false, show ordering.message and don't let the customer pay.
  • Refresh the menu right before checkout and send expected_total.
  • Generate external_id once per checkout; retry with the same one.
  • Charge online after REVO answered 201 (or authorize the card first and capture after order.accepted; refund or void on rejected / cancelled).
  • Show the pickup_code and the live status; use estimated_ready_at for the ready time.
  • Handle rejected (rejection.message) and cancelled gracefully, with an automatic refund for paid orders.
  • Verify webhook signatures and deduplicate by event id.