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 answers403 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.
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"// Node 18+ (fetch is built in). Keep the key on the server.
const API = 'https://api.revoapp.ro/api/public/v1';
const headers = { Authorization: `Bearer ${process.env.REVO_KEY}`, Accept: 'application/json' };
async function revo(path, params = {}) {
const query = new URLSearchParams(params).toString();
const res = await fetch(`${API}${path}${query ? `?${query}` : ''}`, { headers });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
return body;
}
const me = await revo('/me');
console.log(me.business.name, me.scopes, me.all_locations ? 'all locations' : me.location_ids);<?php
const REVO_API = 'https://api.revoapp.ro/api/public/v1';
function revo(string $path, array $params = []): array
{
$url = REVO_API.$path.($params ? '?'.http_build_query($params) : '');
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => ['Authorization: Bearer '.getenv('REVO_KEY'), 'Accept: application/json'],
]);
$body = json_decode((string) curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status >= 400) {
throw new RuntimeException("$status {$body['error']['code']}: {$body['error']['message']}");
}
return $body;
}
$me = revo('/me');
echo $me['business']['name'].' — '.implode(', ', $me['scopes']).PHP_EOL;import os
import requests
API = "https://api.revoapp.ro/api/public/v1"
session = requests.Session()
session.headers.update({"Authorization": f"Bearer {os.environ['REVO_KEY']}", "Accept": "application/json"})
def revo(path, **params):
res = session.get(f"{API}{path}", params=params, timeout=30)
body = res.json()
if res.status_code >= 400:
raise RuntimeError(f"{res.status_code} {body['error']['code']}: {body['error']['message']}")
return body
me = revo("/me")
print(me["business"]["name"], me["scopes"]){
"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 }
}3. Read the menu
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):
{
"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"
}priceis the POS price, VAT included. Whenunitiskgthe product is sold by weight and the price is per kilogram.available: falsemeans 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 /categorieslists categories (location_id: null= shared by every location);GET /allergensis 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"// `store` is your database: upsert(row), remove(id), and a saved mark per list.
async function syncProducts(store) {
const since = await store.getMark('products'); // null the first time
const rows = await listAll('/products', since ? { updated_since: since } : {});
let newest = since;
for (const row of rows) {
if (row.deleted) await store.remove(row.id);
else await store.upsert(row);
if (!newest || row.updated_at > newest) newest = row.updated_at;
}
if (newest) {
// Start the next sync two minutes earlier: changes still being saved are caught.
await store.setMark('products', new Date(Date.parse(newest) - 120_000).toISOString());
}
}function revo_sync_products(PDO $db, ?string $since): ?string
{
$rows = revo_list('/products', $since ? ['updated_since' => $since] : []);
$newest = $since;
foreach ($rows as $row) {
if ($row['deleted']) {
$db->prepare('DELETE FROM revo_products WHERE id = ?')->execute([$row['id']]);
} else {
$db->prepare('REPLACE INTO revo_products (id, json) VALUES (?, ?)')->execute([$row['id'], json_encode($row)]);
}
$newest = max($newest ?? '', $row['updated_at']);
}
// The next mark: two minutes before the newest change.
return $newest ? gmdate('Y-m-d\TH:i:s\Z', strtotime($newest) - 120) : null;
}from datetime import datetime, timedelta
def sync_products(store, since=None):
rows = list_all("/products", **({"updated_since": since} if since else {}))
newest = since
for row in rows:
if row["deleted"]:
store.remove(row["id"])
else:
store.upsert(row)
newest = max(newest or "", row["updated_at"])
if not newest:
return None
# The next mark: two minutes before the newest change.
mark = datetime.fromisoformat(newest.replace("Z", "+00:00")) - timedelta(minutes=2)
return mark.strftime("%Y-%m-%dT%H:%M:%SZ")- 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.
availableandcategory_idon/productsandbelow_minon/ingredientsanswer422together withupdated_sinceorinclude_deleted: a product that goes on 86 would stop matching and never reach your copy. Sync everything, then show onlyavailableproducts on your side.location_idis accepted (see the last point for products that move between locations). - The same works for
/categories,/ingredientsand/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_atis 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"const week = await revo('/sales/daily', { from: '2026-10-01', to: '2026-10-07' });
for (const day of week.data) console.log(day.date, day.orders, day.net_sales);
console.log('Total', week.totals.net_sales, week.currency);$week = revo('/sales/daily', ['from' => '2026-10-01', 'to' => '2026-10-07']);
foreach ($week['data'] as $day) {
printf("%s %3d orders %s RON\n", $day['date'], $day['orders'], $day['net_sales']);
}
echo 'Total '.$week['totals']['net_sales'].' RON'.PHP_EOL;week = revo("/sales/daily", **{"from": "2026-10-01", "to": "2026-10-07"})
for day in week["data"]:
print(day["date"], day["orders"], day["net_sales"])
print("Total", week["totals"]["net_sales"], week["currency"]){
"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 withupdated_sinceinstead: an order that is no longer a sale comes back as a tombstone with areason(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"const low = await listAll('/ingredients', { below_min: 'true' });
for (const i of low) console.log(`${i.name}: ${i.stock} ${i.unit} (minimum ${i.min_stock})`);foreach (revo_list('/ingredients', ['below_min' => 'true']) as $i) {
echo "{$i['name']}: {$i['stock']} {$i['unit']} (minimum {$i['min_stock']})".PHP_EOL;
}for i in list_all("/ingredients", below_min="true"):
print(f"{i['name']}: {i['stock']} {i['unit']} (minimum {i['min_stock']})")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_minisstock≤min_stock, exactly what REVO shows as „sub minim”. An ingredient without a minimum (0) counts once it is out of stock.?below_min=trueis 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.
{
"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"] }
}
}| HTTP | code | What to do |
|---|---|---|
| 400 | bad_request | The body is not a JSON object or is larger than 64 KB. Fix the request. |
| 401 | unauthorized | Missing, wrong or deleted key. Do not retry; ask the restaurant for a key. |
| 403 | insufficient_scope | The key lacks the scope (details.required_scope). The restaurant can add it in Setări → Chei API. |
| 403 | location_forbidden | The key is limited to other locations. |
| 403 | api_key_disabled | The restaurant switched the key off. |
| 403 | api_disabled | Ordering routes only: the restaurant switched online ordering off. |
| 404 | not_found | No such id in this restaurant, or in the locations this key may use. |
| 409 | location_unavailable, items_unavailable, price_changed, cannot_cancel, webhook_not_configured | Ordering only. See the ordering guide. |
| 422 | validation_failed | A malformed parameter or body; details.fields names it. |
| 429 | rate_limited | Wait Retry-After seconds, then retry. |
| 500 | server_error | Retry with backoff (for POST /orders, with the same external_id). |
| 503 | service_unavailable | Temporarily 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}. limitis 1–200 (default 50). Passnext_cursorback ascursor, with the same filters, untilhas_moreisfalse. A cursor is opaque: do not build or change it. A broken one answers422, 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_sinceis 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 from0001-01-01T00:00:00Z(the zero time of Go and .NET, a fine "from the start") to the end of 9999 works; anything else answers422.include_deleted=trueadds tombstones to a plain list.updated_sincealways 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 /ordersand 30 calls of/sales/daily+/sales/productsper minute per key. - Every answer to a valid, active key (errors included) carries
X-RateLimit-LimitandX-RateLimit-Remainingof the tightest limit that applies (normally the per-key 120; on the two sales aggregates their 30).401,403 api_key_disabled,503and a URL that is no endpoint carry none. Over a limit the answer is429withRetry-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
429for 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'));function revo_retry(callable $call, int $tries = 5)
{
for ($attempt = 0; ; $attempt++) {
try {
return $call();
} catch (RuntimeException $e) {
$status = (int) substr($e->getMessage(), 0, 3);
if ($attempt >= $tries - 1 || ! in_array($status, [429, 500, 503], true)) {
throw $e;
}
sleep(min(60, 2 ** $attempt));
}
}
}
$today = revo_retry(fn () => revo('/sales/daily'));import time
def with_retry(call, tries=5):
for attempt in range(tries):
try:
return call()
except RuntimeError as err:
status = int(str(err)[:3])
if attempt == tries - 1 or status not in (429, 500, 503):
raise
time.sleep(min(60, 2 ** attempt))
today = with_retry(lambda: 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));
}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'] ?? 0)) <= 300 && hash_equals($expected, $p['v1'] ?? '');
}import hashlib, hmac, time
def verify_revo(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
expected = hmac.new(secret.encode(), f"{parts.get('t', '')}.".encode() + raw_body, hashlib.sha256).hexdigest()
fresh = abs(time.time() - int(parts.get("t", 0))) <= 300
return fresh and hmac.compare_digest(expected, parts.get("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.