REVO API · Getting started

From a key to synced data in five minutes

Authenticate, read the menu, keep a copy in sync, pull daily sales and stock. Pick a language once: every example on this page follows it.

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

1. Get a key

The restaurant creates keys in REVO → Setări → Chei API → Creează o cheie. For each key it chooses:

  • Scopes, what the key may use: locations:read, menu:read, sales:read, stock:read, orders:write. Anything else answers 403 insufficient_scope.
  • Locations: all of them (including ones added later) or only some. Rows of other locations never come back, and totals add up only the allowed ones.

The key (rvo_live_…) is shown once. Store it as a secret on your server, for example in an environment variable REVO_KEY. One key belongs to one restaurant and only ever sees that restaurant's data. Prefer one key per integration with the fewest scopes it needs.

Test keys (rvo_test_…) only change how orders behave: test orders never reach the kitchen, the fiscal printer, stock or reports. With read scopes, a test key reads the restaurant's real data.

2. Your first request

GET /me needs no scope. It says which restaurant, scopes and locations the key serves.

export REVO_KEY=rvo_live_…   # from REVO → Setări → Chei API
curl https://api.revoapp.ro/api/public/v1/me \
  -H "Authorization: Bearer $REVO_KEY" \
  -H "Accept: application/json"
JSON
{
  "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 }
}

GET /products (scope menu:read) lists every product of the allowed locations, not only what is sold online. Lists come in pages; follow next_cursor while has_more is true.

A product, abridged (all fields are in the reference):

JSON
{
  "id": "8e0f1a2b-…",
  "deleted": false,
  "location_id": "9a1b3c4d-…",
  "category_id": "c2d4e6f8-…",
  "name": "Margherita",
  "price": "32.00",
  "unit": "piece",
  "vat_rate": 11,
  "deposit": null,
  "available": true,
  "allergens": [{ "code": "gluten", "name": "Gluten" }, { "code": "dairy", "name": "Lactate" }],
  "calories": null,
  "channels": {
    "online": { "enabled": true, "price": "32.00" },
    "glovo": { "enabled": true, "price": "36.00" },
    "wolt": { "enabled": false, "price": "32.00" }
  },
  "variants": [{ "id": "v_8e0f…_8c14c8aed9", "name": "Mare", "price": "41.00", "price_delta": "9.00" }],
  "extra_groups": [{ "id": "g_8e0f…_extra", "name": "Extra", "min": 0, "max": null, "required": false,
                     "options": [{ "id": "x_8e0f…_7830799225", "name": "Mozzarella", "price": "5.00" }] }],
  "updated_at": "2026-10-05T09:12:44Z"
}
  • price is the POS price, VAT included. When unit is kg the product is sold by weight and the price is per kilogram.
  • available: false means sold out right now ("86").
  • Variant, group and option ids are deterministic and the same ones the ordering menu uses. They change when the restaurant renames the item, so match them again after a sync.
  • GET /categories lists categories (location_id: null = shared by every location); GET /allergens is the restaurant's allergen catalogue.

4. Keep a copy in sync

Do the full read once. After that, ask only for what changed: send updated_since and you get every row changed at or after that instant. A row deleted in REVO comes back as a tombstone, {"id": "…", "deleted": true, "deleted_at": "…"}: remove it from your copy.

curl "https://api.revoapp.ro/api/public/v1/products?updated_since=2026-10-05T12:00:00Z&limit=200" \
  -H "Authorization: Bearer $REVO_KEY"
  • Treat every row as an upsert: some rows come twice (the two-minute overlap, or a row edited while you were reading), and that is harmless.
  • Sync without the filters that change with the data. available and category_id on /products and below_min on /ingredients answer 422 together with updated_since or include_deleted: a product that goes on 86 would stop matching and never reach your copy. Sync everything, then show only available products on your side. location_id is accepted (see the last point for products that move between locations).
  • The same works for /categories, /ingredients and /sales/orders.
  • Run a full read now and then (for example nightly) as a safety net. It also picks up a product 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.

5. Sales

With sales:read, the figures match the restaurant's REVO dashboard exactly:

  • an order counts when it is paid and not cancelled;
  • net_sales = total − tip − SGR deposit (= items − order discount + delivery fee);
  • it counts on the Bucharest calendar day it was opened; paid_at is there if you prefer to group by payment;
  • refunds are reported on the day they were made, not subtracted;
  • no customer data: no names, phones, e-mails, invoice details or notes. No staff names either.
curl "https://api.revoapp.ro/api/public/v1/sales/daily?from=2026-10-01&to=2026-10-07" \
  -H "Authorization: Bearer $REVO_KEY"
JSON
{
  "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" }
}
  • GET /sales/products?from=&to=&group_by=period|day: quantity, weight (for products sold by weight), gross, line discounts and net per product. Order-level discounts stay in /sales/daily.
  • GET /sales/orders?from=&to=: the paid orders themselves, with lines, payments and refunds. Mirror them with updated_since instead: an order that is no longer a sale comes back as a tombstone with a reason (cancelled, unpaid, deleted).
  • Ranges are at most 92 days. The two aggregates allow 30 calls a minute per key.

6. Stock

With stock:read, GET /ingredients lists every ingredient of the allowed locations with the shelf stock as REVO's stock journal holds it now. Sales, receptions, waste, transfers and counts all move it. It may be negative when goods were sold before their reception was booked.

curl "https://api.revoapp.ro/api/public/v1/ingredients?below_min=true" \
  -H "Authorization: Bearer $REVO_KEY"

Each row has stock, min_stock and below_min, the unit, category, warehouse (gestiune), SKU, barcode and allergens. Purchase prices are not part of v1.

  • below_min is stock ≤ min_stock, exactly what REVO shows as „sub minim”. An ingredient without a minimum (0) counts once it is out of stock.
  • ?below_min=true is for a one-off read like the one above. To mirror stock, sync without it (see §4) and filter on your side.

7. Errors and retries

Every error has the same envelope. message is for you (English). display_message is Romanian text you may show your user: neutral on the data routes („Cheia nu are acces la această locație.”), worded for an online customer on the ordering routes.

JSON
{
  "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"] }
  }
}
HTTPcodeWhat to do
400bad_requestThe body is not a JSON object or is larger than 64 KB. Fix the request.
401unauthorizedMissing, wrong or deleted key. Do not retry; ask the restaurant for a key.
403insufficient_scopeThe key lacks the scope (details.required_scope). The restaurant can add it in Setări → Chei API.
403location_forbiddenThe key is limited to other locations.
403api_key_disabledThe restaurant switched the key off.
403api_disabledOrdering routes only: the restaurant switched online ordering off.
404not_foundNo such id in this restaurant, or in the locations this key may use.
409location_unavailable, items_unavailable, price_changed, cannot_cancel, webhook_not_configuredOrdering only. See the ordering guide.
422validation_failedA malformed parameter or body; details.fields names it.
429rate_limitedWait Retry-After seconds, then retry.
500server_errorRetry with backoff (for POST /orders, with the same external_id).
503service_unavailableTemporarily unavailable; retry later with backoff.

Retry only 429, 500 and 503 (and network errors), with growing waits: 1 s, 2 s, 4 s… up to a minute. Never retry the other 4xx unchanged.

8. Pagination

  • Lists answer {"data": [...], "next_cursor": "…", "has_more": true}.
  • limit is 1–200 (default 50). Pass next_cursor back as cursor, with the same filters, until has_more is false. A cursor is opaque: do not build or change it. A broken one answers 422, never a silent restart from page one.
  • Rows come oldest change first. A row that does not change while you walk comes exactly once. A row edited during the walk moves to the end and comes again with its new values, so treat every row as an upsert. A change that was still being saved while you walked reaches you in the next sync, thanks to the two-minute overlap of §4.
  • updated_since is an ISO 8601 instant: 2026-10-05T12:00:00Z, or with an offset such as +03:00 (URL-encode the + as %2B). Without an offset it is UTC. Any instant from 0001-01-01T00:00:00Z (the zero time of Go and .NET, a fine "from the start") to the end of 9999 works; anything else answers 422.
  • include_deleted=true adds tombstones to a plain list. updated_since always includes them.

9. Rate 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.
  • 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). 401, 403 api_key_disabled, 503 and a URL that is no endpoint carry none. Over a limit the answer is 429 with Retry-After.
  • After 30 requests with a missing or wrong key in a minute from one address (the one that connects to REVO), that address gets 429 for the rest of that minute. A valid key is never held back by it.
async function withRetry(call, tries = 5) {
  for (let attempt = 0; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      const status = Number(String(err.message).slice(0, 3));
      if (attempt >= tries - 1 || ![429, 500, 503].includes(status)) throw err;
      await new Promise((r) => setTimeout(r, Math.min(60_000, 1000 * 2 ** attempt)));
    }
  }
}

const today = await withRetry(() => revo('/sales/daily'));

10. Webhooks

Webhooks are part of online ordering (orders:write). REVO POSTs each status change of the orders your key placed (order.accepted, order.rejected, order.ready, order.completed, order.cancelled, and ping) to the URL the restaurant set for the key. Answer 2xx within 10 seconds. Failures are retried after about 1 min, 5 min, 15 min, 1 h, 6 h and 24 h. Delivery is at least once and not necessarily in order: deduplicate by the event id.

Verify every delivery with the webhook secret (whsec_…) shown with the key. The header is Revo-Signature: t=<unix seconds>,v1=<hex>, where v1 = hex(HMAC_SHA256(secret, t + "." + raw body)). Accept it only if it matches in constant time and t is within 5 minutes.

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));
}

The full ordering flow (menu, placing orders, statuses, test mode) is in the ordering guide.

11. Security checklist

  • Call the API from your server. Never put a key in a web page, a mobile app binary or a public repository. Browsers can only reach the API from REVO's own pages.
  • One key per integration, with the fewest scopes and locations it needs.
  • Keep keys in a secret store or environment variables, never in logs or tickets.
  • Rotating a key: the restaurant chooses „Lasă cheia veche să meargă încă 24 de ore”, you deploy the new key, and the old one stops by itself.
  • If a key leaks, the restaurant disables or deletes it in Setări → Chei API. It stops at once.
  • Verify webhook signatures and use HTTPS everywhere.

12. Versioning

v1 only ever adds fields, parameters and endpoints. Ignore fields you do not know. Something incompatible would ship as /v2, with at least six months in which both work. Every change is listed in the API changelog. The whole contract is in the interactive reference and as an OpenAPI 3.1 file: openapi.yaml · openapi.json.