Integrări API
Pagina descrie integrarea prin care site-ul unui partener vorbește cu FlowManager. FlowManager rămâne casa de marcat și sala: produsele, mesele, bucătăria și încasarea stau aici. Site-ul nu se leagă de rutele interne ale aplicației. Folosește doar /partner/v1, apelat din backend-ul lui, cu o cheie de locație.
Cheia se generează în Configurări → Integrări API și se trimite în header-ul Authorization: Bearer. Textul complet se arată o singură dată. În bază rămâne hash-ul. O cheie de pe o locație de test are prefixul fm_test_ și vede doar punctul marcat sandbox. Pe o locație reală, cheia iese direct de producție, cu prefixul fm_live_, și nu vede punctele de test.
Cu integrarea, site-ul poate:
- Afișa meniul punctului: categorii, preț, TVA, disponibilitate, stocul live pe bucăți, poza live și declarația nutrițională, inclusiv alergenii și aditivii.
- Pune un QR pe fiecare masă. Id-ul mesei vine din lista de mese și se trimite la comandă, iar nota se deschide pe masa din CashFlow.
- Primi comenzi de ridicare și la pachet. Ridicarea se așază pe masa Comenzi Online din plan.
- Întreba statusul unei comenzi și, dacă e bifat dreptul, citi rapoartele de vânzări deja calculate.
- Primi un webhook când se schimbă meniul sau statusul unei comenzi făcute prin API.
Rețetele, costurile și stocul de ingrediente nu pleacă spre site. Stocul live, pe bucăți, da: site-ul îl poate folosi ca disponibilitate. Livrarea cu curier nu este un tip de comandă; o adresă poate sta în nota comenzii. Facturile către ANAF, casa de marcat și imprimantele rămân în FlowManager.
Test din consolă
Fiecare bloc de mai jos este un segment separat. Schimbi valorile din primul segment, apoi rulezi doar apelul pe care vrei să-l vezi. Curl-urile citesc KEY, POINT și BASE din terminalul același. Scripturile Node sunt de sine stătătoare: valorile de schimbat stau la începutul fiecăruia.
Valorile pe care le schimbi
Cheia de test din Integrări API, id-ul punctului sandbox și adresa serverului.
export KEY='fm_test_CHEIA' export POINT='POINT_ID' export BASE='https://flowmanager.ro/api'
curl — meniu
curl -sS "$BASE/partner/v1/menu?point=$POINT" \ -H "Authorization: Bearer $KEY"
curl — mese
curl -sS "$BASE/partner/v1/tables?point=$POINT" \ -H "Authorization: Bearer $KEY"
curl — ridicare
Înlocuiește PRODUCT_ID cu un id din meniu.
curl -sS -X POST "$BASE/partner/v1/orders" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d @- <<JSON
{"point":"$POINT","externalId":"consola-pickup-1","channel":"pickup","customer":{"name":"Test"},"lines":[{"productId":"PRODUCT_ID","quantity":1}]}
JSON
curl — comandă la masă
Înlocuiește PRODUCT_ID și TABLE_ID. TABLE_ID este id din lista de mese.
curl -sS -X POST "$BASE/partner/v1/orders" \
-H "Authorization: Bearer $KEY" \
-H "Content-Type: application/json" \
-d @- <<JSON
{"point":"$POINT","externalId":"consola-masa-1","channel":"dine_in","tableId":"TABLE_ID","customer":{"name":"Test"},"lines":[{"productId":"PRODUCT_ID","quantity":1}]}
JSON
curl — status
Înlocuiește ORDER_ID cu id-ul FlowManager sau cu externalId.
curl -sS "$BASE/partner/v1/orders/ORDER_ID" \ -H "Authorization: Bearer $KEY"
Node — meniu
node --input-type=module <<'EOF'
const KEY = 'fm_test_CHEIA';
const POINT = 'POINT_ID';
const BASE = 'https://flowmanager.ro/api';
const res = await fetch(BASE + '/partner/v1/menu?point=' + POINT, {
headers: { Authorization: 'Bearer ' + KEY },
});
const menu = await res.json();
console.log(res.status);
console.log(menu.products?.map((item) => ({
id: item.id,
name: item.name,
image: item.image,
kcal: item.nutrition?.energy?.kcal,
stock: item.stock,
})));
EOF
Node — mese
node --input-type=module <<'EOF'
const KEY = 'fm_test_CHEIA';
const POINT = 'POINT_ID';
const BASE = 'https://flowmanager.ro/api';
const res = await fetch(BASE + '/partner/v1/tables?point=' + POINT, {
headers: { Authorization: 'Bearer ' + KEY },
});
console.log(res.status);
console.log(await res.json());
EOF
Node — comandă la masă
node --input-type=module <<'EOF'
const KEY = 'fm_test_CHEIA';
const POINT = 'POINT_ID';
const BASE = 'https://flowmanager.ro/api';
const PRODUCT_ID = 'PRODUCT_ID';
const TABLE_ID = 'TABLE_ID';
const res = await fetch(BASE + '/partner/v1/orders', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({
point: POINT,
externalId: 'consola-' + Date.now(),
channel: 'dine_in',
tableId: TABLE_ID,
customer: { name: 'Test API' },
lines: [{ productId: PRODUCT_ID, quantity: 1 }],
}),
});
console.log(res.status);
console.log(await res.text());
EOF
Cheia
Drepturile sunt separate: meniu, comenzi, status, rapoarte. Cheia este a locației. Parametrul point trebuie să fie un punct al acelei locații. Pe locația de test, cheia generată este de test. Pe locația reală, cheia generată este de producție. O cheie de test funcționează doar pe un punct marcat sandbox, de pe aceeași pagină. O cheie revocată rămâne în listă, dar nu mai trece.
GET /partner/v1/menu
Request. Dreptul menu. Dacă trimiți ETag-ul primit, iar meniul nu s-a schimbat, răspunsul este 304, fără corp.
GET /partner/v1/menu?point=POINT_ID Authorization: Bearer fm_live_... If-None-Match: "ETAG"
Răspuns 200. Header ETag.
image este o adresă HTTPS live. Site-ul o pune direct în imagine. Șirul gol înseamnă că produsul nu are poză. nutrition este declarația nutrițională pentru basis (100g sau 100ml): energie în kJ și kcal, grăsimi și acizi grași saturați, glucide și zaharuri, proteine, sare. Valorile 0 înseamnă că declarația nu a fost completată. allergens și additives sunt liste de nume. Aceeași declarație există și pe subprodus, când porția are valori proprii.
available este comutatorul din fișa produsului: produsul e în meniu sau e oprit. stock este stocul live, pe bucăți. Se setează pe produs, dacă punctul are „Activează stoc live” în setările de produs: comutatorul Stoc live și câmpul Cantitate stoc live. Aceeași pereche există și pe subprodus.
- stock.active false: cantitatea nu ține disponibilitatea. Site-ul se uită doar la available.
- stock.active true: stock.quantity este câte bucăți au rămas. La fiecare vânzare care pleacă spre secție, inclusiv comanda prin API și vânzarea de la casă, cantitatea scade cu numărul de bucăți vândute.
- Când cantitatea ajunge la 0, produsul rămâne în meniu, dar site-ul îl poate marca epuizat. O comandă care cere mai mult decât a rămas primește 409 out_of_stock.
- Schimbarea stocului actualizează meniul. După câteva secunde pleacă webhook-ul menu.updated, iar următorul GET întoarce cantitatea nouă.
Stocul de ingrediente din rețetă nu se trimite. stock este doar numărul de bucăți ținut pe produs.
{
"categories": [{ "id": "CAT_ID", "name": "Cafea", "order": 1 }],
"products": [{
"id": "PRODUCT_ID",
"name": "Espresso",
"description": "",
"price": 9,
"tva": 11,
"available": true,
"stock": { "active": true, "quantity": 12 },
"image": "https://flowmanager.ro/media/images/espresso.webp",
"categoryId": "CAT_ID",
"nutrition": {
"basis": "100ml",
"energy": { "kj": 8, "kcal": 2 },
"fat": 0.1,
"saturatedFat": 0,
"carbs": 0,
"sugars": 0,
"protein": 0.1,
"salt": 0
},
"allergens": [],
"additives": [],
"toppings": [{ "name": "Lapte", "price": 2 }],
"subProducts": [{
"id": "SUB_ID",
"name": "Mic",
"price": 8,
"tva": 11,
"available": true,
"stock": { "active": false, "quantity": 0 },
"image": "",
"nutrition": {
"basis": "100ml",
"energy": { "kj": 8, "kcal": 2 },
"fat": 0.1,
"saturatedFat": 0,
"carbs": 0,
"sugars": 0,
"protein": 0.1,
"salt": 0
},
"allergens": [],
"additives": []
}]
}]
}
Eroare.
401 { "error": "unauthorized", "message": "Cheie lipsă sau invalidă." }
401 { "error": "revoked", "message": "Cheia este revocată." }
403 { "error": "forbidden", "message": "Cheia nu are acest drept." }
403 { "error": "forbidden", "message": "Punctul aparține altei locații." }
404 { "error": "not_found", "message": "Punctul de lucru nu există." }
GET /partner/v1/tables
Request. Dreptul menu. Lista meselor punctului, pentru QR-ul de la masă. id se pune în tableId la comanda dine_in. number este numărul din plan, name denumirea, area zona.
GET /partner/v1/tables?point=POINT_ID Authorization: Bearer fm_live_...
Răspuns 200.
{
"tables": [
{ "id": "TABLE_ID", "number": 3, "name": "Terasă 3", "area": "Terasă" },
{ "id": "ONLINE_TABLE_ID", "number": 6, "name": "Comenzi Online", "area": "Principal" }
]
}
Eroare. Aceleași coduri ca la meniu: 401, 403 și 404 pentru punct.
POST /partner/v1/orders
Request. Dreptul orders. externalId este id-ul din sistemul partenerului. Același corp trimis din nou întoarce comanda existentă, cu 200. Un corp diferit pe același id întoarce 409. dine_in cere tableId, id-ul primit de la GET /partner/v1/tables, și ajunge pe masa aceea. pickup este ridicare. takeaway este la pachet. Comanda intră pe același drum ca o comandă de client: secțiile de print și fiscalizarea rămân cele ale punctului.
Pentru comenzile online de ridicare, în planul de mese al punctului trebuie să existe o masă cu numele exact Comenzi Online. Comanda se așază pe ea. Locațiile noi, de test și reale, o primesc deja în zona Principal. Pe o locație mai veche, masa se adaugă manual în plan, cu acest nume.
POST /partner/v1/orders
Authorization: Bearer fm_live_...
Content-Type: application/json
{
"point": "POINT_ID",
"externalId": "sb-1042",
"channel": "pickup",
"note": "Str. Exemplu 1, etaj 2",
"customer": { "name": "Ana", "telephone": "0700000000", "email": "ana@exemplu.ro" },
"lines": [{ "productId": "PRODUCT_ID", "quantity": 2, "toppingNames": ["Lapte"], "comment": "" }]
}
Răspuns 200.
{
"id": "ORDER_ID",
"externalId": "sb-1042",
"status": "open",
"cancelled": false,
"total": 22,
"channel": "pickup",
"point": "POINT_ID",
"prepStatus": "open",
"createdAt": "2026-10-12T12:00:00.000Z"
}
Eroare. 401 și 403 sunt ca la meniu. În plus:
404 { "error": "not_found", "message": "Produsul nu există pe acest punct." }
409 { "error": "unavailable", "message": "Espresso nu este disponibil." }
409 { "error": "out_of_stock", "message": "Espresso nu are stoc suficient. Au rămas 2." }
409 { "error": "conflict", "message": "externalId este deja folosit pentru o altă comandă." }
409 { "error": "closed", "message": "Suntem închiși." }
400 { "error": "invalid_table", "message": "Comanda în locație are nevoie de tableId." }
404 { "error": "not_found", "message": "Masa nu a fost găsită" }
GET /partner/v1/orders/:id
Request. Dreptul status. id poate fi id-ul FlowManager sau externalId.
GET /partner/v1/orders/sb-1042 Authorization: Bearer fm_live_...
Răspuns 200. Același corp ca la crearea comenzii. cancelled este adevărat când comanda a fost anulată.
Eroare.
401 { "error": "unauthorized", "message": "Cheie lipsă sau invalidă." }
403 { "error": "forbidden", "message": "Cheia nu are acest drept." }
404 { "error": "not_found", "message": "Comanda nu există." }
GET /partner/v1/reports
Request. Dreptul reports. Fără point, răspunsul adună punctele locației pe care cheia le poate vedea. Sunt rapoartele deja calculate, nu un calcul nou.
GET /partner/v1/reports?from=2026-10-01&to=2026-10-07&point=POINT_ID Authorization: Bearer fm_live_...
Răspuns 200.
{
"from": "2026-10-01",
"to": "2026-10-07",
"days": [{
"day": "2026-10-01",
"point": "POINT_ID",
"cashIn": 1200,
"cashInNoVat": 1081.08,
"vatValue": 118.92,
"ordersCount": 40,
"products": [{ "name": "Espresso", "qty": 30, "gross": 270 }]
}]
}
Eroare. 401, 403 și 404 sunt ca la meniu. Un interval greșit:
400 { "error": "invalid_range", "message": "Intervalul from/to este invalid." }
Webhook
Webhook-ul este apelul pe care FlowManager îl face către site, când se schimbă ceva. Site-ul nu întreabă din minut în minut. Își publică o adresă, iar FlowManager trimite acolo un POST scurt. Meniul complet și statusul comenzii se citesc în continuare cu GET-urile de mai sus.
Unde se leagă
În Configurări → Integrări API se pune un singur URL pe locație, nu pe punct. Adresa trebuie să fie https. localhost este acceptat doar pentru probe. La salvare se arată o dată secretul. Se păstrează la site, pentru verificarea semnăturii. Golirea URL-ului oprește webhook-ul. Rotirea secretului îl înlocuiește; URL-ul rămâne același, iar secretul vechi nu mai semnează.
Ce primește site-ul
- Un POST cu Content-Type: application/json și corpul brut, exact cum a fost semnat.
- Header-ul X-Flowmanager-Event: menu.updated sau order.status.
- Header-ul X-Flowmanager-Signature: sha256=.... Valoarea de după sha256= este HMAC-SHA256 al corpului brut, calculat cu secretul.
- Site-ul răspunde cu un status de succes, 2xx, cât mai repede. Așteptarea este de 5 secunde. Nu se reîncearcă trimiterea.
Verificarea, pe corpul primit neschimbat:
import { createHmac, timingSafeEqual } from 'crypto';
const secret = 'SECRETUL_ARATAT_O_DATA';
function isFlowmanager(rawBody, signatureHeader) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const got = String(signatureHeader || '').replace(/^sha256=/, '');
const a = Buffer.from(expected);
const b = Buffer.from(got);
return a.length === b.length && timingSafeEqual(a, b);
}
menu.updated
Pleacă când se salvează un produs, un subprodus sau o categorie a punctului, inclusiv când scade stocul live. Dacă se schimbă mai multe lucruri la rând, se trimite un singur apel, la circa 2 secunde după ultima modificare. Corpul nu conține meniul. Conține punctul și ETag-ul. Site-ul cere apoi GET /partner/v1/menu cu If-None-Match. Dacă ETag-ul e același, răspunsul este 304.
{
"id": "evt_ab12",
"type": "menu.updated",
"createdAt": "2026-10-12T12:00:00.000Z",
"data": { "point": "POINT_ID", "etag": "ETAG" }
}
order.status
Pleacă doar pentru comenzile create prin API, cele care au externalId. Se trimite la salvare și când se schimbă statusul, anularea, statusul de preparare sau ieșirea comenzii. O comandă făcută doar la casă nu ajunge pe acest webhook. id este id-ul FlowManager. externalId este id-ul din site. cancelled este adevărat când comanda a fost anulată. prepStatus este stadiul de preparare, de exemplu open sau done.
{
"id": "evt_cd34",
"type": "order.status",
"createdAt": "2026-10-12T12:05:00.000Z",
"data": {
"id": "ORDER_ID",
"externalId": "sb-1042",
"status": "done",
"cancelled": false,
"total": 22,
"point": "POINT_ID",
"prepStatus": "done"
}
}
Dacă URL-ul nu răspunde
Comanda și vânzarea din FlowManager rămân salvate. Eșecul webhook-ului nu le anulează. În Integrări API rămâne ultimul rezultat: statusul HTTP, sau mesajul URL-ul partenerului nu răspunde. când adresa nu a răspuns în 5 secunde. Site-ul recuperează ce a ratat citind meniul, cu ETag, și statusul comenzii, cu GET /partner/v1/orders/:id.