Magazinul tău, legat la Clarvo POS

Dacă vrei magazinul tău, scris de tine, nu trebuie să ții meniul în două locuri. Îl iei de aici, iar comenzile le trimiți încoace. Intră în local exact ca una făcută din magazinul nostru: aceeași notă la bucătărie, același client în lista localului și, dacă a bifat acordul, în contactele Clarvo pentru campanii.

1. Pornești puntea și îți iei cheile

În Clarvo POS, la localul tău: Livrări → Setări. Pornește „Magazinul meu, legat la Clarvo”, apoi fă o cheie.

Primești o pereche: cheie (utilizatorul) și secret (parola). Secretul se arată o singură dată. Nu-l păstrăm nici noi în clar, deci nu-l putem arăta a doua oară. Dacă-l pierzi, faci altă cheie și o oprești pe cea veche.

Ține secretul pe server Nu-l pune în JavaScript, în aplicația de telefon sau în pagina publică. Cine îl are poate comanda în numele localului. Locul lui e în .env-ul magazinului tău.

2. Te legi cu cheia și secretul

Se trimit ca utilizator și parolă (HTTP Basic), la fiecare cerere. Nu există jetoane de reînnoit.

curl -u "CHEIA:SECRETUL" https://pos-api.clarvo.ro/api/partener/local

În Laravel:

use Illuminate\Support\Facades\Http;

$clarvo = Http::withBasicAuth(config('clarvo.cheie'), config('clarvo.secret'))
    ->acceptJson()
    ->baseUrl('https://pos-api.clarvo.ro/api/partener');

$local = $clarvo->get('/local')->json();

Dacă nu poți trimite Basic, merg și antetele X-Clarvo-Cheie și X-Clarvo-Secret.

GET /local

Cine e localul, dacă primește comenzi chiar acum și de ce nu, zonele de livrare, metodele de plată, cât durează o ridicare. Întreab-o la fiecare încărcare de pagină: de aici afli dacă arăți butonul „Comandă” sau „Suntem închiși”.

{
  "local": { "nume": "Bistro Central", "oras": "Alba Iulia", "telefon": "0258 800 800", "moneda": "RON", "program": { … } },
  "primeste": true,
  "motiv": null,
  "deschide_la": null,
  "livrare": {
    "activa": true,
    "zone": [
      {
        "id": 1,
        "nume": "Alba Iulia",
        "descriere": null,
        "localitati": ["Alba Iulia"],
        "taxa": 8.99,
        "comanda_minima": 40,
        "prag_gratuit": 60,
        "timp_estimat_min": 45
      }
    ]
  },
  "ridicare": { "activa": true, "timp_min": 20 },
  "plati": [ { "cod": "numerar", "nume": "Numerar la primire" }, … ],
  "programare": { "minim_min": 30, "maxim_ore": 24 }
}
«primeste» e singurul lucru care contează înainte de a arăta coșul E false și când localul e închis, și când a oprit comenzile, și când nici livrarea nici ridicarea nu merg. În motiv ai o frază scrisă pentru client, iar în deschide_la ora la care se redeschide (dacă se știe). Arată-le ca atare, nu le traduce.

3. Livrarea și ridicarea

Zonele se scriu în Clarvo POS, la local, și coboară de aici. Nu le ține la tine: o taxă schimbată acolo trebuie să se vadă pe site, altfel clientul plătește altceva decât scrie pe nota curierului.

Ce e o zonă

CâmpCe faci cu el
localitatiNumele localităților în care se livrează. După ele se potrivește adresa clientului.
taxaCât costă livrarea în zona asta.
comanda_minimaSub suma asta, localul nu livrează acolo. Arată-i clientului cât îi mai lipsește.
prag_gratuitPeste suma asta, livrarea e gratuită. null = nu există prag.
timp_estimat_minÎn cât timp ajunge, ca să-i spui clientului.
Nu trimiți zona la comandă Trimiți adresa, iar zona o alege POS-ul din ea. Ce afișezi tu e doar pentru ca omul să știe dinainte cât plătește. Dacă adresa nu cade în nicio zonă, comanda se refuză cu eroare pe câmpul adresa — așa că potrivește-o și tu înainte, ca să nu afle abia la final.

Cum potrivești adresa cu o zonă

La fel ca noi: cauți numele localității în adresă, fără diacritice și fără majuscule, ca pe un cuvânt întreg. „Str. Moților 12, Alba Iulia” cade în zona cu "localitati": ["Alba Iulia"].

function zonaPentru(array $zone, string $adresa): ?array
{
    $curat = fn (string $t) => mb_strtolower(strtr($t, [
        'ă' => 'a', 'â' => 'a', 'î' => 'i', 'ș' => 's', 'ş' => 's', 'ț' => 't', 'ţ' => 't',
    ]));

    $a = $curat($adresa);

    foreach ($zone as $zona) {
        foreach ($zona['localitati'] as $localitate) {
            if (preg_match('/\b'.preg_quote($curat($localitate), '/').'\b/u', $a)) {
                return $zona;
            }
        }
    }

    return null;   // nu livrăm acolo
}

Dacă preferi, poți să-l pui pe client să aleagă localitatea dintr-o listă, în loc s-o ghicești din text. E mai sigur și îi scutește pe amândoi de greșeli de scriere.

Cât plătește pentru livrare

Regula e scurtă: taxa zonei, în afară de cazul în care subtotalul a trecut de pragul gratuit.

$taxa = ($zona['prag_gratuit'] !== null && $subtotal >= $zona['prag_gratuit'])
    ? 0
    : $zona['taxa'];

Socotește-o și tu, ca s-o poți arăta în coș. Dar la comandă tot POS-ul o pune pe notă, din aceleași reguli — deci cele două sume ies la fel, fără să le sincronizezi.

Ridicarea din local

Dacă ridicare.activa e true, clientul poate veni el. Atunci nu ceri adresă, iar ridicare.timp_min îți spune în cât timp e gata, ca să-i dai o oră.

Comandă pentru mai târziu

Trimite programata_la (ISO 8601). Limitele sunt în programare: cel puțin minim_min minute de acum și cel mult maxim_ore ore. Ora trebuie să cadă în programul localului, altfel primești eroare pe câmpul programata_la.

4. Iei meniul

GET /meniu

Grupele și produsele marcate „vizibil online” în POS, cu prețuri, descrieri, poze, alergeni și opțiuni (garnituri, mărimi). Ce ascunde localul în POS nu ajunge la tine.

Ce trebuie ales la comandă: observatii. Unele produse cer o alegere fără preț, cum ar fi Gătire la vită: [{"titlu": "Gătire", "unic": true, "obligatoriu": true, "valori": ["În sânge", "Medium rare", "Medium", "Medium well", "Bine făcut"]}]. Arată-le clientului ca pe o opțiune obligatorie și trimite valoarea aleasă în note la produs, prima, despărțită prin virgulă de ce a mai scris el ("Medium rare, fără sos"). Așa ajunge la bucătărie, ca de pe telefonul ospătarului. La produsele fără nimic de ales, lista e goală.

Categoriile cu program: program și comanda_oricand. O categorie se poate comanda online doar la anumite ore, ca Mic dejun între 08:00 și 12:00: "program": {"de": "08:00", "pana": "12:00", "zile": [1, 2, 3, 4, 5]}. Zilele merg de la 1 (luni) la 7 (duminică); o listă goală înseamnă în fiecare zi, iar null înseamnă oricând. Capetele sunt incluse. Un interval ca 22:00–02:00 trece peste miezul nopții și ține de ziua în care începe. În afara programului, arată categoria doar cu produsele care au "comanda_oricand": true; dacă nu are niciunul, ascunde-o. O comandă cu un produs din afara programului e refuzată cu 422, pe câmpul produse, sau pe programata_la când ora aleasă iese din program. Mesajul se poate arăta clientului așa cum e. Meniul stă în memoria ta cu tot cu program, deci verifică ora la fiecare afișare, nu doar când îl descarci.

Bucătărie sau bar: sectie. Fiecare categorie spune de unde pleacă: "bucatarie" sau "bar" (la o subcategorie fără secție, cea a categoriei de deasupra). Folosește-o ca să împarți meniul, de pildă în Mâncare și Bar. null înseamnă că localul n-a ales încă.

Alergenii: alergeni și urme_alergeni. Pe fiecare produs, două liste de texte: ce conține (["Gluten", "Lapte", "Ouă"]) și ce poate conține în urme (["Susan"], «Poate conține urme de»). O listă goală înseamnă că localul n-a scris nimic, nu că produsul n-ar avea alergeni. Arată-le în fișa produsului.

Meniul în engleză: traduceri. Categoriile, produsele, grupele de opțiuni și fiecare alegere au cheia traduceri, cu textele pe limbi (deocamdată doar en). Româna rămâne în câmpurile obișnuite; în traduceri e doar ce s-a tradus:

// categoria și grupa de opțiuni, alegerea
"traduceri": {"en": {"nume": "Burgers", "descriere": "Grilled on charcoal"}}
"traduceri": {"en": {"nume": "Size"}}
// produsul
"traduceri": {"en": {"nume": "Classic burger", "descriere": "Beef 180 g, cheddar", "alergeni": ["Gluten", "Milk", "Eggs"], "urme_alergeni": ["Sesame"], "unitate": "pc"}}
// nimic tradus
"traduceri": {}

nume și descriere apar doar dacă localul le-a tradus; altfel arată textul românesc. alergeni, urme_alergeni și unitate vin gata în engleză ori de câte ori produsul le are în română: ce a scris localul, altfel traducerea noastră (cei 14 alergeni din UE și unitățile uzuale: buc → pc, porție → portion), altfel textul românesc. Nimic tradus înseamnă un obiect gol, {}, niciodată o listă. Comanda se trimite la fel, cu aceleași id-uri, oricare ar fi limba în care a ales clientul: la bucătărie ajunge în română.

Modificările din POS se văd la tine fără să faci nimic. Localul schimbă prețul în Clarvo, tu citești meniul, ai prețul nou. Nu ține o copie a meniului în baza ta de date mai mult decât ca memorie temporară.

Ca să nu descarci meniul degeaba, ai două căi:

CaleCum
GET /meniu/versiuneRăspunde cu un șir scurt. Dacă e același ca data trecută, nu s-a schimbat nimic.
Antetul If-None-MatchTrimite ETag-ul primit. Dacă nimic nu s-a schimbat, primești 304 și niciun octet de conținut.
$versiune = cache()->get('clarvo_meniu_versiune');
$r = $clarvo->withHeaders($versiune ? ['If-None-Match' => '"'.$versiune.'"'] : [])->get('/meniu');

if ($r->status() === 304) {
    $meniu = cache()->get('clarvo_meniu');       // n-a mișcat nimic
} else {
    $meniu = $r->json();
    cache()->put('clarvo_meniu', $meniu, now()->addHour());
    cache()->put('clarvo_meniu_versiune', $meniu['versiune'], now()->addHour());
}

Afli pe loc când s-a schimbat meniul. Dacă cheia are o adresă de anunț, îți trimitem acolo un POST imediat după ce localul schimbă ceva din meniu, din zonele de livrare sau din setările livrării (ambalajul, de exemplu), semnat la fel ca anunțul de stare:

X-Clarvo-Eveniment: meniu.schimbat
X-Clarvo-Semnatura: sha256=<hmac_sha256(corpul cererii, secretul adresei de anunț)>

{"eveniment": "meniu.schimbat", "la": "2026-10-20T19:05:00+03:00", "versiune": "3f9c2a1b7d4e8f60"}

Răspunde repede cu 200 și descarcă meniul după răspuns (GET /meniu, GET /local). Dacă adresa ta nu răspunde cu 2xx, repetăm anunțul în fiecare minut până ajunge. O verificare rară (de exemplu, o dată pe oră) rămâne utilă ca plasă de siguranță.

5. Trimiți comanda

POST /comenzi

CâmpCe e
nume, telefonObligatorii. nume e numele întreg, cum apare pe notă. Pe telefon îl sună localul dacă e ceva cu comanda.
prenume, nume_familieOpționale, dacă magazinul tău le cere separat. Trimite-le pe lângă nume, nu în locul lui. Ajung pe clientul localului și în contactele Clarvo CRM, unde campaniile salută pe prenume.
emailObligatoriu doar la plata cu cardul: acolo pleacă confirmarea plății.
tiplivrare sau ridicare.
adresaObligatorie la livrare. Zona o alege POS-ul din ea — tu nu trimiți zona. Dacă nu cade în niciuna, primești eroare pe câmpul adresa.
plataUnul dintre codurile din GET /local (numerar, card_la_livrare, card_online). Dacă clientul a plătit deja în magazinul tău, cu procesatorul tău, trimite platit_online: casa nu mai încasează nimic, iar la «Acceptă» emite bonul pe card.
referintaRecomandat: numărul comenzii la tine (de exemplu A19-260926-0001). Dacă răspunsul nostru se pierde pe drum și retrimiți, aceeași referință nu face a doua comandă: primești comanda de data trecută, cu repetata: true.
ambalajDoar când localul a lăsat ambalajul opțional (ambalaj.mod = "optional" în GET /local): true dacă clientul l-a cerut. La obligatoriu se pune singur: prețul din ambalaj.pret × bucățile comenzii, fără produsele cu „ambalaj inclus” în nume; dacă ambalajul e deja în coș (ambalaj.produs_id, sau un ambalaj din categoria ambalaj.grupa_id), nu se mai adaugă. Arată-l în coș pe o linie a lui, cu bucățile și prețul, iar totalul de dinainte de butonul de comandă să-l cuprindă (OUG 34/2014); la «opțional», bifa pornește nebifată (art. 22).
Ambalajul categoriei (ambalaj.regula = "categorie"): fiecare produs din GET /meniu are ambalaj, al lui ({"id": 849, "nume": "Cutie pizza", "pret": 2} sau null). Pune o linie pe fiecare ambalaj, cu bucățile adunate din produsele care îl au (o bucată pe fiecare unitate începută: 1,5 kg = 2), cu prețul lui. Dacă în coș e deja un produs din ambalaj.ambalaje, nu se mai pune nimic. La "unic", ca până acum.
total_afisatOpțional, recomandat: totalul pe care l-a văzut clientul chiar înainte de butonul de comandă, cu livrarea și ambalajul. Dacă nota ar ieși cu altă sumă (s-a schimbat un preț între timp), comanda nu intră: primești eroare pe câmpul total, cu suma de acum. Așa nu se încasează niciodată altceva decât ce a văzut clientul.
produse[]produs_id din meniu, cantitate, optiuni (id-uri de alegeri), note (cu valoarea aleasă din observatii, dacă produsul cere una).
programata_laOpțional, pentru mai târziu. Limitele sunt în GET /local.
acordNu mai e nevoie de el. Datele de mai sus sunt necesare ca să pregătim și să livrăm comanda, deci nu cer o bifă de consimțământ: pune în magazinul tău o notă că datele se folosesc pentru comandă și un link spre politica de confidențialitate. Dacă îl trimiți în continuare, e ignorat.
acord_marketingOpțional, bifat separat de client (nebifat din pornire). Doar cu el ajunge în campaniile localului. Fără el, clientul intră în lista localului și în contactele Clarvo CRM, dar nu primește campanii.
acord_marketing_la, acord_marketing_sursaOpționale, doar împreună cu acord_marketing: true: momentul în care a bifat (ISO 8601) și unde (ex. magazinul-tau.ro/comanda). Sunt dovada acordului (GDPR). Fără ele, se scrie ora la care a ajuns comanda la noi și „Comenzi online”. Un moment din viitor sau mai vechi de o săptămână e înlocuit cu ora primirii.
$r = $clarvo->post('/comenzi', [
    'nume' => 'Maria Pop',
    'prenume' => 'Maria',
    'nume_familie' => 'Pop',
    'telefon' => '0722 111 222',
    'tip' => 'livrare',
    'adresa' => 'Str. Moților 12, Alba Iulia',
    'detalii_adresa' => 'Bl. 3, ap. 7',
    'plata' => 'numerar',
    'note' => 'Fără ceapă.',
    'produse' => [
        ['produs_id' => 12, 'cantitate' => 2, 'optiuni' => [4, 9]],
        ['produs_id' => 31, 'cantitate' => 1, 'note' => 'Medium rare, fără sos'],   // «Gătire» din observatii
    ],
    'acord_marketing' => true,
    'acord_marketing_la' => now()->toIso8601String(),   // clipa bifei
    'acord_marketing_sursa' => 'magazinul-tau.ro/comanda',
]);

$comanda = $r->json('comanda');   // ->['uuid'], ->['total'], ->['stare']
Totalul îl calculăm noi Nu trimite prețuri. Prețurile, taxa de zonă, comanda minimă și pragul de livrare gratuită se socotesc aici, din meniul localului, în clipa comenzii. Așa nu se poate comanda la un preț vechi.

Localul acceptă comanda

Dacă acceptare din GET /local e manuala, comanda apare pe casa localului și nu pleacă la bucătărie până nu apasă cineva «Acceptă». Până atunci starea e primita. Spune-i clientului că localul o confirmă în câteva minute; mesajul din răspuns (mesaj) spune deja asta. Dacă localul o refuză, starea devine anulata, iar în mesaj apare motivul.

GET /comenzi/{uuid}

Unde a ajuns comanda. Stările, în ordine:

stareCe înseamnă
primitaAm primit-o; localul o acceptă în câteva minute.
acceptataAcceptată, programată: pleacă la bucătărie la ora ei.
in_pregatireAcceptată, bucătăria lucrează la ea.
gata / gata_de_ridicareGata (la livrare: pleacă în curând; la ridicare: clientul poate veni).
pe_drumÎn livrare, cu curierul.
livrata / ridicataÎncheiată.
anulata / nelivrataRefuzată, anulată sau nelivrată; motivul e în mesaj.

Afli pe loc când se schimbă starea

Pune la cheie o adresă de anunț (în Clarvo POS → Livrări → Puntea). La fiecare schimbare de stare trimitem acolo un POST cu comanda, în aceeași formă ca GET /comenzi/{uuid}:

POST https://magazinul-tau.ro/clarvo/stare
X-Clarvo-Eveniment: comanda.stare
X-Clarvo-Semnatura: sha256=<hmac_sha256(corpul cererii, secretul adresei de anunț)>

{"eveniment": "comanda.stare", "la": "2026-09-26T18:20:00+03:00", "comanda": {"uuid": "…", "referinta": "A19-260926-0001", "stare": "in_pregatire", "stare_nume": "În pregătire", "mesaj": "…", …}}

Verifică semnătura înainte să crezi anunțul (hash_equals), apoi trimite-i clientului emailul stării noi. Dacă adresa ta nu răspunde, anunțul nu se repetă: întreabă din când în când GET /comenzi/{uuid} cât e comanda în lucru, ca plasă de siguranță.

6. Plata cu cardul

Dacă localul are plata cu cardul pornită (vezi plati din GET /local) și trimiți plata: "card_online", răspunsul are și un bloc plata cu o adresă:

$plata = $r->json('plata');
if ($plata) {
    return redirect()->away($plata['url']);   // pagina de plată
}

Clientul plătește acolo și se întoarce. Comanda nu pleacă la bucătărie până când plata nu e confirmată de procesator — noi nu o credem plătită pe cuvântul nimănui. Starea o vezi cu GET /comenzi/{uuid}. Fără plată în 30 de minute, comanda se anulează singură.

Dacă magazinul tău stă într-un cadru Paginile de plată refuză să fie puse în iframe. Dacă magazinul tău e încadrat undeva, trimite fereastra mare la plată, nu cadrul, altfel clientul vede o casetă goală exact când plătește.

7. Erorile

CodCe înseamnăCe faci
401Cheia sau secretul nu sunt bune, cheia e oprită, sau localul a stins puntea.Citește mesaj: spune care din ele. Nu reîncerca în buclă.
422Comanda nu poate fi primită: local închis, adresă în afara zonelor, sub comanda minimă, produs epuizat.errors are câmpul vinovat. Arată mesajul clientului: e scris pentru el, în română.
404Comanda nu e a acestui local.Verifică uuid-ul.
429Prea multe cereri într-un minut.Ține meniul în memorie și nu-l cere la fiecare vizitator.

Toate mesajele de eroare sunt scrise ca să poată fi arătate clientului ca atare, fără să le traduci.

Ce NU face puntea