API reference

The OpenAPI 3.1 reference verified against the code that serves it, the integration guides and the tested examples.

Version 1.0.0

API base URL

https://oxjqgjbiltoviwldailg.supabase.co/functions/v1/core-api

On this page

GroceryAI API · integration guide

The complete reference, route by route, is the OpenAPI 3.1 document (openapi.json): response shapes, the error codes of every status (x-error-codes) and the events (webhooks). This guide explains the rules that hold for every route. The examples in examples/ are tested on every run against the platform's code. The OpenAPI descriptions are in Italian; field names, codes and examples are the same in every language.

Base URL: https://oxjqgjbiltoviwldailg.supabase.co/functions/v1/core-api followed by the path (/v1/products…). Every response carries x-request-id (the same value is request_id in the body) and x-core-version: if you write to us, send the request_id.

1. Keys and authentication

Every call carries Authorization: Bearer <key>. You create keys in the portal (Developers → API keys) and see them once: copy the key somewhere safe straight away.

shapegai_<environment>_<8-char prefix>_<32-char secret>, e.g. gai_test_ab12cd34_…
prefixgai_test_ab12cd34 is public: you see it in the portal and in the activity log; on its own it opens nothing
environmenttest or live. It separates the data, not just the label: a test key only sees test catalogue, customers and orders, a live key only real ones
scopescatalog:read, catalog:write, customers:read, customers:write, orders:read, orders:write. None includes another: catalog:write does not let you read the catalogue
publishable keygai_pk_… is for the chat on your website and has no scopes: on this API it gets 403 AMBITO_MANCANTE

A key that does not open gets 401 with a precise code: CHIAVE_ASSENTE (missing), CHIAVE_MALFORMATA (malformed), CHIAVE_SCONOSCIUTA (unknown), CHIAVE_REVOCATA (revoked), CHIAVE_SCADUTA (expired), CHIAVE_INCOERENTE (prefix does not match the key). A store that is not active gets 403 TENANT_SOSPESO; a key without the route's scope 403 AMBITO_MANCANTE.

2. Errors

One shape, on every route and for every error, ours or yours:

{ "error": { "type": "richiesta_non_valida", "code": "PARAMETRO_NON_VALIDO", "message": "`limit` deve essere un intero fra 1 e 200.", "param": "limit" },
  "request_id": "6f1c…" }

  • code is what you program against: the possible values for every route and status are in x-error-codes in the document. Codes are stable identifiers (in Italian, like the rest of the platform's vocabulary).
  • type is the family and depends only on the HTTP status: 400 and 405 richiesta_non_valida, 401 autenticazione, 403 autorizzazione, 404 non_trovato, 409 conflitto, 413 troppo_grande, 429 limite_richieste, 503 servizio_non_disponibile, other 5xx errore_interno.
  • message is for humans and may change; param is there when the error concerns a field or a parameter.
  • A path that does not exist is 404 ROTTA_SCONOSCIUTA; an existing path with the wrong method is 405 METODO_NON_AMMESSO. The key is checked before the path: without a valid key you get the 401.
  • A 5xx never carries internal details: the request_id is enough for us to find it.

3. Idempotency

Every call that writes (PUT /v1/products, PUT /v1/inventory, PUT /v1/customers, the POST …/ack) requires an Idempotency-Key header: a value of yours, at most 255 characters. Without it: 400 IDEMPOTENCY_KEY_MANCANTE.

  • Same key, same body: you get the same response (status, body and request_id of the first time) with idempotent-replayed: true, and nothing runs again. So you can safely repeat a call whose response you did not see (dropped connection, timeout).
  • Same key, different body: 409 IDEMPOTENCY_KEY_RIUSATA.
  • Same key while the first call is still running: 409 RICHIESTA_IN_CORSO; retry in a few seconds.
  • A key is scoped to the store (across all its API keys and both environments) and does not expire: use a new value for every distinct operation (a UUID, or an id from your system such as ack-<event_id>).
  • The outcome of a call that failed after being accepted is recorded too (a 404 on the ack of an order that does not exist, a 5xx): repeating it with the same key gives you that outcome again, marked idempotent-replayed. To really retry, use a new key. Errors that happen earlier (API key, scopes, rate limit, body not JSON or too large, Idempotency-Key missing or too long) do not consume the key.

4. Limits

requestsper API key, in one-minute windows (120 per minute unless set differently for that key). Beyond: 429 LIMITE_RICHIESTE with Retry-After = seconds to the end of the window
bodyat most 1 MiB: beyond, 413 CORPO_TROPPO_GRANDE
batchesat most 500 items per call: beyond, 400 LOTTO_TROPPO_GRANDE; an empty list is 400 LOTTO_VUOTO

In a batch an invalid item does not sink the batch: the response says how many arrived, how many were written and which were rejected, with position and reason.

{ "received": 3, "written": 2, "rejected": 1,
  "errors": [{ "index": 1, "id": "SKU-9", "code": "GTIN_NON_VALIDO", "message": "…" }],
  "request_id": "…" }

index is the position in your batch (from 0); it is null when the database rejected the item after validation (RIFIUTATA_DAL_DATABASE). On PUT /v1/inventory an SKU that does not exist is SKU_SCONOSCIUTO: no half product is created. Prices are loaded in the store's currency: another currency is VALUTA_DIVERSA_DAL_TENANT, never converted.

5. Pagination

  • Catalogue (GET /v1/products): sorted by sku; limit from 1 to 200 (50 by default); ask for the next page with cursor = the previous next_cursor. next_cursor is present only when the page is full, so a last full page leads to one more call that comes back empty. Filters: sku, gtin.
  • Orders and support requests (GET /v1/orders, GET /v1/support_requests): oldest first, limit from 1 to 200. There is no cursor: the queue moves forward with the ack. status=new (default) returns what you have not acknowledged yet, status=acked what you have.

6. Events by webhook

When a customer confirms an order (order.confirmed) or asks for a member of staff (support.requested), the platform sends a signed POST to every endpoint of the store subscribed to that event. The portal's "Send a test event" button sends a webhook.ping (environment test) to the endpoint under test only.

The body is always the same envelope:

{ "id": "2b1e…", "type": "order.confirmed", "environment": "live", "occurred_at": "2026-10-13T09:00:00.000Z",
  "resource_id": "ORD-1042", "attempt": 1, "data": { "schema_version": 2, "order": { … } } }

data depends on the type; the exact shapes are in the document (webhooks, schemas OrdineConfermato, RichiestaAssistenza, Ping). environment tells a test from a real event: the endpoints are the same.

Verifying the signature

Every delivery carries:

header
x-gai-event-idthe event id, the same on every attempt
x-gai-event-typethe type
x-gai-attemptthe attempt number, from 1
x-gai-timestampUnix seconds when this attempt was signed
x-gai-signaturesha256= + hex HMAC-SHA256 of <x-gai-timestamp>.<raw body>
  1. Take the raw body, the exact bytes received: parsing and re-serialising the JSON changes the bytes and the signature will not match.
  2. Compute HMAC-SHA256 of <x-gai-timestamp> + . + body, keyed with the endpoint's whole signing secret, whsec_ prefix included, exactly as the portal showed it when you created the endpoint.
  3. Compare sha256=<hex> with x-gai-signature in constant time.
  4. Reject when x-gai-timestamp is more than 300 seconds away from your clock: a genuine delivery captured and replayed later fails here, even with the right signature.

Complete, tested examples: examples/verify-signature.mjs (Node) and examples/verify_signature.py (Python).

Responding and retries

  • Answer 2xx within 10 seconds and do the work afterwards. Any other answer, or no answer in time, is a failed attempt.
  • There are six attempts: the first within about a minute of the event, then each one after the previous by 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours (about eight and a half hours in all). After the sixth the delivery is abandoned; you see it in the portal (Developers → Webhooks → Deliveries), where you can send it again.
  • Every attempt is signed again, with its own x-gai-timestamp and attempt; id and data do not change.
  • The same event can reach you more than once (a retry after a 2xx we never received, a delivery resent from the portal): drop duplicates by id.
  • The secret is shown once, when the endpoint is created. An endpoint deactivated and then reactivated gets a new secret.

7. Pull

If you do not want an inbound endpoint, take orders and requests when you choose: GET /v1/orders?status=new (scope orders:read), save them, and acknowledge with POST /v1/orders/{order_id}/ack (scope orders:write, with Idempotency-Key). The same for GET /v1/support_requests and POST /v1/support_requests/{support_id}/ack.

  • You only see what belongs to your key's environment.
  • Every item carries event_id (the same id as the webhook), the identifier to pass to the ack, payload (the same data as the webhook), occurred_at and created_at, and stato (in_attesa, "pending", until you acknowledge, then consegnato, "delivered", with consegnato_at).
  • Acknowledging twice is not an error. Until you acknowledge, the item comes back on every read: save by id, idempotently, and if you crash between saving and acknowledging you will see it again instead of losing it.
  • Orders older than 11 October 2026 follow version 1 of the contract: no schema_version, and the customer only has chatId.

Complete, tested example: examples/pull-orders.mjs.

Endpoint reference

Endpoint descriptions are written in Italian.

L'API della piattaforma GroceryAI, verso di noi: catalogo, prezzi e giacenze, clienti, stato degli ordini.

Autenticazione: una chiave API per tenant, in Authorization: Bearer gai_live_…. Le chiavi hanno un ambiente (test o live) che separa i dati, non solo le etichette, e degli ambiti (catalog:read, catalog:write, customers:read, customers:write, orders:read, orders:write). Il valore in chiaro si vede una volta sola.

Lotti: le scritture accettano fino a 500 elementi per chiamata; ogni elemento e' validato da solo e quelli rifiutati sono elencati nella risposta, invece di far cadere il lotto.

Idempotenza: ogni chiamata che scrive vuole Idempotency-Key. Rigiocarla con lo stesso corpo restituisce la stessa risposta senza rieseguire.

Tracciabilita': ogni risposta, riuscita o no, porta request_id. E' l'unica cosa che chiediamo quando ci si scrive.

Eventi: gli ordini confermati e le richieste di assistenza arrivano all'endpoint del negozio come webhook firmati (sezione webhooks), o si ritirano in pull (GET /v1/orders, GET /v1/support_requests) con l'ack.

Authentication

http bearerLa chiave API del tenant (gai_test_… o gai_live_…). Una chiave pubblicabile (gai_pk_…) non ha ambiti: qui riceve 403 AMBITO_MANCANTE.

GET/v1/openapi.json

Il documento OpenAPI di questa API

Generato dalla stessa tavola di rotte che serve le chiamate: non puo' divergere dal comportamento.

Scope: any valid secret key, no particular scope needed.

Responses

PUT/v1/products

Carica o aggiorna il catalogo, a lotti

Upsert per sku. Il gtin e' facoltativo, ma se c'e' deve essere un GTIN/GS1 valido (8, 12, 13 o 14 cifre, cifra di controllo compresa): un codice a barre sbagliato e' peggio di un codice assente.

Scope: catalog:write

Parameters

  • Idempotency-Keyheaderstringrequired

    Chiave scelta dal chiamante. La stessa chiave con lo stesso corpo restituisce la stessa risposta senza rieseguire; con un corpo diverso e' un 409.

    maxLength: 255

Request bodyrequired

Lotto di prodotti. Al massimo 500 elementi per chiamata; ogni elemento e' validato da solo, e quelli rifiutati sono elencati nella risposta invece di far cadere l'intero lotto.

object

Responses

GET/v1/products

Elenca il catalogo del tenant

Ordinato per sku. La paginazione e' a cursore: cursor e' l'ultimo sku della pagina precedente.

Scope: catalog:read

Parameters

  • skuquerystring

    Un solo prodotto, per SKU.

  • gtinquerystring

    I prodotti con questo GTIN.

  • limitqueryinteger

    Da 1 a 200 (default 50).

  • cursorquerystring

    Ultimo sku della pagina precedente.

Responses

PUT/v1/inventory

Aggiorna prezzi e giacenze, a lotti

Tocca SOLO i campi presenti in ogni elemento (price, net_price, stock_qty, available): quello che non si nomina resta com'era. Uno SKU che non esiste e' un elemento rifiutato, non un prodotto creato a meta'.

Scope: catalog:write

Parameters

  • Idempotency-Keyheaderstringrequired

    Chiave scelta dal chiamante. La stessa chiave con lo stesso corpo restituisce la stessa risposta senza rieseguire; con un corpo diverso e' un 409.

    maxLength: 255

Request bodyrequired

Lotto di giacenze e prezzi. Al massimo 500 elementi per chiamata; ogni elemento e' validato da solo, e quelli rifiutati sono elencati nella risposta invece di far cadere l'intero lotto.

object

Responses

PUT/v1/customers

Sincronizza i clienti, a lotti

Upsert per external_ref, cioe' l'identificativo che il cliente ha nel sistema del tenant.

Scope: customers:write

Parameters

  • Idempotency-Keyheaderstringrequired

    Chiave scelta dal chiamante. La stessa chiave con lo stesso corpo restituisce la stessa risposta senza rieseguire; con un corpo diverso e' un 409.

    maxLength: 255

Request bodyrequired

Lotto di clienti. Al massimo 500 elementi per chiamata; ogni elemento e' validato da solo, e quelli rifiutati sono elencati nella risposta invece di far cadere l'intero lotto.

object

Responses

GET/v1/customers/{external_ref}

Legge un cliente

Un external_ref di un altro tenant risponde 404, non 403: l'esistenza di una riga altrui non e' un'informazione da dare.

Scope: customers:read

Parameters

  • external_refpathstringrequired

    L'identificativo del cliente nel sistema del tenant.

Responses

GET/v1/orders

Gli ordini da prendere in carico (via pull)

L'alternativa ai webhook per chi non vuole un endpoint in ingresso: si legge status=new e si conferma con l'ack. Legge il registro degli eventi (outbox) della piattaforma, filtrato per l'ambiente della chiave: una chiave test vede gli ordini di prova, una chiave live quelli veri.

Scope: orders:read

Parameters

  • statusquerystring

    new (default) oppure acked.

  • limitqueryinteger

    Da 1 a 200 (default 50).

Responses

POST/v1/orders/{order_id}/ack

Conferma di aver preso in carico un ordine

Da quel momento l'ordine non torna piu' in status=new. Confermare due volte lo stesso ordine non e' un errore: e' la stessa risposta.

Scope: orders:write

Parameters

  • order_idpathstringrequired

    L'identificativo dell'ordine.

  • Idempotency-Keyheaderstringrequired

    Chiave scelta dal chiamante. La stessa chiave con lo stesso corpo restituisce la stessa risposta senza rieseguire; con un corpo diverso e' un 409.

    maxLength: 255

Request body

Vuoto, oppure {}.

object

Vuoto, oppure {}.

maxProperties: 0

Responses

GET/v1/support_requests

Le richieste di assistenza da prendere in carico (via pull)

Un cliente della chat ha chiesto una persona, ha fatto un reclamo o vuole cambiare un ordine gia' inviato: la richiesta arriva per la stessa via degli ordini. Si legge status=new e si conferma con l'ack. Filtrata per l'ambiente della chiave, come gli ordini.

Scope: orders:read

Parameters

  • statusquerystring

    new (default) oppure acked.

  • limitqueryinteger

    Da 1 a 200 (default 50).

Responses

POST/v1/support_requests/{support_id}/ack

Conferma di aver preso in carico una richiesta di assistenza

Da quel momento la richiesta non torna piu' in status=new. Confermare due volte la stessa richiesta non e' un errore: e' la stessa risposta.

Scope: orders:write

Parameters

  • support_idpathstringrequired

    L'identificativo della richiesta (support_request.id).

  • Idempotency-Keyheaderstringrequired

    Chiave scelta dal chiamante. La stessa chiave con lo stesso corpo restituisce la stessa risposta senza rieseguire; con un corpo diverso e' un 409.

    maxLength: 255

Request body

Vuoto, oppure {}.

object

Vuoto, oppure {}.

maxProperties: 0

Responses

Routing errors, on every endpoint

  • 404 ROTTA_SCONOSCIUTANessuna rotta per quel metodo e quel percorso.
  • 405 METODO_NON_AMMESSOIl percorso esiste, ma non per quel metodo (un 404 manderebbe a cercare un URL che invece esiste).

Response headers

  • x-request-idstring

    L'identificativo di QUESTA chiamata: uguale a request_id nel corpo, tranne nel rigioco (idempotent-replayed: true), dove il corpo e' quello della chiamata originale e porta il suo request_id.

  • x-core-versionstring

    La versione della piattaforma che ha risposto.

  • retry-afterinteger

    Secondi da aspettare prima di riprovare: fino alla fine della finestra del minuto.

  • idempotent-replayedstring

    true quando la risposta e' il rigioco di una chiamata gia' eseguita con la stessa Idempotency-Key: stesso stato, stesso corpo, stesso request_id della prima volta, e nessuna nuova esecuzione.

Webhook events

order.confirmed

Un cliente ha confermato un ordine nella chat del negozio. Arriva una volta sola per ordine (resource_id = l'identificativo dell'ordine) a ogni endpoint iscritto a order.confirmed; lo stesso ordine si ritira anche in pull con GET /v1/orders.

Headers

  • x-gai-event-idheaderstringrequired

    L'id dell'evento: lo stesso a ogni tentativo. E' la chiave con cui si scartano i doppioni.

    format: uuid

  • x-gai-event-typeheaderstringrequired

    Il tipo dell'evento, uguale a type nel corpo.

  • x-gai-attemptheaderstringrequired

    Il numero del tentativo, da 1. Uguale a attempt nel corpo.

    pattern: ^[1-9][0-9]*$

  • x-gai-timestampheaderstringrequired

    Secondi Unix dell'istante della firma. Cambia a ogni tentativo.

    pattern: ^[0-9]+$

  • x-gai-signatureheaderstringrequired

    sha256= seguito dall'HMAC-SHA256 esadecimale di <x-gai-timestamp>.<corpo grezzo>, con chiave il segreto di firma dell'endpoint (whsec_…, usato per intero).

    pattern: ^sha256=[0-9a-f]{64}$

  • user-agentheaderstringrequired

    Sempre GroceryAI-Core-Events/1.

Event body

object

  • idstringrequired

    L'identificativo dell'evento (uguale a x-gai-event-id).

    format: uuid

  • typestringrequired

    Values: "order.confirmed"

  • environmentstringrequired

    Una prova (test) o un fatto vero (live): gli endpoint sono gli stessi.

    Values: "test""live"

  • occurred_atstringrequired

    Quando e' accaduto il fatto (non quando e' partita la consegna).

    format: date-time

  • resource_idstringrequired

    L'identificativo dell'ordine (data.order.id).

  • attemptintegerrequired

    Il numero del tentativo (uguale a x-gai-attempt).

    minimum: 1

  • dataOrdineConfermatorequired

Responses

  • 2XXConsegnato. Si risponde presto (entro 10 secondi) e si lavora dopo: oltre, il tentativo e' fallito.

  • defaultQualunque altra risposta, o nessuna risposta entro 10 secondi: si riprova con i ritardi di x-retry-schedule-seconds.

support.requested

Un cliente della chat chiede una persona, fa un reclamo o vuole cambiare un ordine gia' inviato. Arriva a ogni endpoint iscritto a support.requested; la stessa richiesta si ritira anche in pull con GET /v1/support_requests.

Headers

  • x-gai-event-idheaderstringrequired

    L'id dell'evento: lo stesso a ogni tentativo. E' la chiave con cui si scartano i doppioni.

    format: uuid

  • x-gai-event-typeheaderstringrequired

    Il tipo dell'evento, uguale a type nel corpo.

  • x-gai-attemptheaderstringrequired

    Il numero del tentativo, da 1. Uguale a attempt nel corpo.

    pattern: ^[1-9][0-9]*$

  • x-gai-timestampheaderstringrequired

    Secondi Unix dell'istante della firma. Cambia a ogni tentativo.

    pattern: ^[0-9]+$

  • x-gai-signatureheaderstringrequired

    sha256= seguito dall'HMAC-SHA256 esadecimale di <x-gai-timestamp>.<corpo grezzo>, con chiave il segreto di firma dell'endpoint (whsec_…, usato per intero).

    pattern: ^sha256=[0-9a-f]{64}$

  • user-agentheaderstringrequired

    Sempre GroceryAI-Core-Events/1.

Event body

object

  • idstringrequired

    L'identificativo dell'evento (uguale a x-gai-event-id).

    format: uuid

  • typestringrequired

    Values: "support.requested"

  • environmentstringrequired

    Una prova (test) o un fatto vero (live): gli endpoint sono gli stessi.

    Values: "test""live"

  • occurred_atstringrequired

    Quando e' accaduto il fatto (non quando e' partita la consegna).

    format: date-time

  • resource_idstringrequired

    L'identificativo della richiesta (data.support_request.id).

  • attemptintegerrequired

    Il numero del tentativo (uguale a x-gai-attempt).

    minimum: 1

  • dataRichiestaAssistenzarequired

Responses

  • 2XXConsegnato. Si risponde presto (entro 10 secondi) e si lavora dopo: oltre, il tentativo e' fallito.

  • defaultQualunque altra risposta, o nessuna risposta entro 10 secondi: si riprova con i ritardi di x-retry-schedule-seconds.

webhook.ping

L'evento di prova del pulsante «invia un evento di prova» del portale: una consegna sola, all'endpoint provato, firmata come le altre. Non si ritira in pull.

Headers

  • x-gai-event-idheaderstringrequired

    L'id dell'evento: lo stesso a ogni tentativo. E' la chiave con cui si scartano i doppioni.

    format: uuid

  • x-gai-event-typeheaderstringrequired

    Il tipo dell'evento, uguale a type nel corpo.

  • x-gai-attemptheaderstringrequired

    Il numero del tentativo, da 1. Uguale a attempt nel corpo.

    pattern: ^[1-9][0-9]*$

  • x-gai-timestampheaderstringrequired

    Secondi Unix dell'istante della firma. Cambia a ogni tentativo.

    pattern: ^[0-9]+$

  • x-gai-signatureheaderstringrequired

    sha256= seguito dall'HMAC-SHA256 esadecimale di <x-gai-timestamp>.<corpo grezzo>, con chiave il segreto di firma dell'endpoint (whsec_…, usato per intero).

    pattern: ^sha256=[0-9a-f]{64}$

  • user-agentheaderstringrequired

    Sempre GroceryAI-Core-Events/1.

Event body

object

  • idstringrequired

    L'identificativo dell'evento (uguale a x-gai-event-id).

    format: uuid

  • typestringrequired

    Values: "webhook.ping"

  • environmentstringrequired

    Una prova (test) o un fatto vero (live): gli endpoint sono gli stessi.

    Values: "test"

  • occurred_atstringrequired

    Quando e' accaduto il fatto (non quando e' partita la consegna).

    format: date-time

  • resource_idnullrequired

  • attemptintegerrequired

    Il numero del tentativo (uguale a x-gai-attempt).

    minimum: 1

  • dataPingrequired

Responses

  • 2XXConsegnato. Si risponde presto (entro 10 secondi) e si lavora dopo: oltre, il tentativo e' fallito.

  • defaultQualunque altra risposta, o nessuna risposta entro 10 secondi: si riprova con i ritardi di x-retry-schedule-seconds.

Event delivery

  • Retries, in seconds after the previous attempt: 60, 300, 1800, 7200, 21600
  • Maximum attempts: 6
  • Time to respond: 10000 ms
  • Signature: HMAC-SHA256(segreto, ${x-gai-timestamp}.${corpo grezzo}), esadecimale, preceduto da sha256=

Schemas

RequestId

string

L'identificativo della chiamata: e' anche nell'intestazione x-request-id. E' l'unica cosa che chiediamo quando ci si scrive.

format: uuid

Errore

object

  • errorobjectrequired

    • typestringrequired

      La famiglia dell'errore, derivata dallo stato HTTP.

      Values: "richiesta_non_valida""autenticazione""autorizzazione""non_trovato""conflitto""troppo_grande""limite_richieste""servizio_non_disponibile""errore_interno"

    • codestringrequired

      Il codice preciso: e' su questo che si programma. I valori possibili per ogni risposta sono in x-error-codes.

    • messagestringrequired

      Per chi legge, non per il codice: il testo puo' cambiare.

    • paramstring

      Il campo o il parametro in causa, quando ce n'e' uno.

  • request_idRequestIdrequired

ProdottoInIngresso

object

Un elemento non valido non fa cadere il lotto: torna in errors con il suo code. I numeri si accettano anche come stringhe numeriche.

  • skustringrequired

    minLength: 1maxLength: 128

  • titlestringrequired

    minLength: 1maxLength: 512

  • gtinstring

    GTIN/GS1 con la cifra di controllo giusta.

    pattern: ^([0-9]{8}|[0-9]{12,14})$

  • brandstring

  • categorystring

  • unitstring

  • pricenumber

    minimum: 0

  • net_pricenumber

    minimum: 0

  • currencystring

    ISO 4217; se manca, quella del tenant. Una valuta diversa da quella del tenant si rifiuta: non si converte.

    pattern: ^[A-Z]{3}$

  • stock_qtynumber

    minimum: 0

  • availableboolean

    Se manca, true.

  • linkstring

  • image_linkstring

  • attributesobject

GiacenzaInIngresso

object

Tocca SOLO i campi presenti. Un elemento senza nessuno dei quattro campi e' rifiutato (NESSUN_CAMPO).

At least one of: pricenet_pricestock_qtyavailable

  • skustringrequired

    minLength: 1

  • pricenumber

    minimum: 0

  • net_pricenumber

    minimum: 0

  • stock_qtynumber

    minimum: 0

  • availableboolean

ClienteInIngresso

object

  • external_refstringrequired

    L'identificativo del cliente nel sistema del tenant.

    minLength: 1maxLength: 128

  • display_namestring

  • emailstring

  • phonestring

  • addressobject

  • attributesobject

EsitoLottoProdotti

object

  • receivedintegerrequired

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerrequired

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerrequired

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>required

    • indexinteger | nullrequired

      La posizione nel lotto; null quando a rifiutare e' stato il database dopo la validazione.

    • idstring | nullrequired

      Lo sku o l'external_ref dell'elemento, quando c'era.

    • codestringrequired

      Values: "ELEMENTO_NON_OGGETTO""SKU_MANCANTE""SKU_TROPPO_LUNGO""TITLE_MANCANTE""TITLE_TROPPO_LUNGO""GTIN_NON_VALIDO""PREZZO_NON_VALIDO""GIACENZA_NON_VALIDA""VALUTA_NON_VALIDA""VALUTA_DIVERSA_DAL_TENANT""DISPONIBILITA_NON_VALIDA""RIFIUTATA_DAL_DATABASE"

    • messagestringrequired

  • request_idRequestIdrequired

EsitoLottoGiacenze

object

  • receivedintegerrequired

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerrequired

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerrequired

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>required

    • indexinteger | nullrequired

      La posizione nel lotto; null quando a rifiutare e' stato il database dopo la validazione.

    • idstring | nullrequired

      Lo sku o l'external_ref dell'elemento, quando c'era.

    • codestringrequired

      Values: "ELEMENTO_NON_OGGETTO""SKU_MANCANTE""PREZZO_NON_VALIDO""GIACENZA_NON_VALIDA""DISPONIBILITA_NON_VALIDA""NESSUN_CAMPO""SKU_SCONOSCIUTO"

    • messagestringrequired

  • request_idRequestIdrequired

EsitoLottoClienti

object

  • receivedintegerrequired

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerrequired

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerrequired

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>required

    • indexinteger | nullrequired

      La posizione nel lotto; null quando a rifiutare e' stato il database dopo la validazione.

    • idstring | nullrequired

      Lo sku o l'external_ref dell'elemento, quando c'era.

    • codestringrequired

      Values: "ELEMENTO_NON_OGGETTO""EXTERNAL_REF_MANCANTE""EXTERNAL_REF_TROPPO_LUNGO""EMAIL_NON_VALIDA""RIFIUTATA_DAL_DATABASE"

    • messagestringrequired

  • request_idRequestIdrequired

Prodotto

object

  • skustringrequired

  • gtinstring | nullrequired

  • titlestringrequired

  • brandstring | nullrequired

  • categorystring | nullrequired

  • unitstring | nullrequired

  • pricenumber | nullrequired

  • net_pricenumber | nullrequired

  • currencystringrequired

  • stock_qtynumber | nullrequired

  • availablebooleanrequired

  • linkstring | nullrequired

  • image_linkstring | nullrequired

  • attributesobjectrequired

Cliente

object

  • external_refstringrequired

  • display_namestring | nullrequired

  • emailstring | nullrequired

  • phonestring | nullrequired

  • addressobjectrequired

  • attributesobjectrequired

OrdineDaRitirare

object

  • event_idstringrequired

    L'id dell'evento: lo stesso che arriva col webhook.

    format: uuid

  • order_idstringrequired

    Si passa all'ack.

  • typestringrequired

    Values: "order.confirmed"

  • payloadOrdineConfermatorequired

  • occurred_atstringrequired

    format: date-time

  • created_atstringrequired

    format: date-time

  • statostringrequired

    in_attesa in status=new, consegnato in status=acked.

    Values: "in_attesa""consegnato"

  • consegnato_atstring | nullrequired

    L'istante del primo ack.

    format: date-time

RichiestaDaRitirare

object

  • support_idstringrequired

    Si passa all'ack.

  • event_idstringrequired

    format: uuid

  • typestringrequired

    Values: "support.requested"

  • payloadRichiestaAssistenzarequired

  • occurred_atstringrequired

    format: date-time

  • created_atstringrequired

    format: date-time

  • statostringrequired

    Values: "in_attesa""consegnato"

  • consegnato_atstring | nullrequired

    format: date-time

ClienteDellEvento

object

Il cliente a cui consegnare, letto dalla sua riga e mai dal modello. Il canale web non ha recapiti: phone ed email sono null e sono elencati in missing_fields.

  • chatIdstringrequired

    L'identita' opaca del cliente nel canale.

  • channelstring | null

  • fullNamestring | null

  • addressstring | null

  • citystring | null

  • zipstring | null

  • phonestring | null

  • emailstring | null

  • notesstring | null

  • missing_fieldsarray<string>

    I campi che il negozio non ricevera'.

OrdineConfermato

object

Il corpo di order.confirmed (data del webhook, payload del pull). schema_version 2 dall'11/10/2026: porta il cliente intero. Gli eventi della versione 1, ancora nel registro e quindi nel pull, non hanno il campo e il loro cliente ha il solo chatId.

  • schema_versioninteger

    Values: 2

  • orderobjectrequired

    • idstringrequired

    • statusstringrequired

      Values: "confirmed"

    • linesarray<object>required

      • skustring | nullrequired

      • namestringrequired

      • brandstring | nullrequired

      • qtynumberrequired

      • unitstring | nullrequired

      • unit_pricenumber | nullrequired

        null vuol dire «da reperire», non zero.

      • out_of_catalogbooleanrequired

    • totalsobjectrequired

      Importi nella valuta del tenant, calcolati dal database.

      • subtotalnumber | null

      • discountsnumber | null

      • deliverynumber | null

      • extrasnumber | null

      • totalnumber | null

    • deliveryobjectrequired

      La fascia di consegna scelta; vuoto se non c'e'.

      • startISOstring

        format: date-time

      • endISOstring

        format: date-time

      • labelstring

    • customerClienteDellEventorequired

    • notesstring | nullrequired

      La nota dell'ordine, o le note del cliente quando quella manca.

RichiestaAssistenza

object

Il corpo di support.requested (data del webhook, payload del pull).

  • schema_versionintegerrequired

    Values: 1

  • support_requestobjectrequired

    • idstringrequired

      Anche la chiave d'idempotenza: lo stesso messaggio ricevuto due volte non produce due richieste.

    • kindstringrequired

      Values: "cancel_order""modify_order""order_status""complaint""third_party_order""contact_request""price_match""fragile""payment_link""payment_received""other""name_unverified"

    • outcomestringrequired

      nuova, oppure un caso gia' segnalato a cui il cliente aggiunge un fatto (cumulativa) o su cui insiste (escalation).

      Values: "nuova""cumulativa""escalation"

    • customer_messagestring | nullrequired

      Il testo del cliente (al piu' 300 caratteri), mai una parafrasi.

    • langstring | nullrequired

    • channelstringrequired

    • order_codestring | nullrequired

    • customerClienteDellEventorequired

Ping

object

  • testbooleanrequired

    Values: true

  • endpoint_idintegerrequired

    L'endpoint provato.

Tested examples

Signature verification (Node)

verify-signature.mjs · javascript

// Verify a GroceryAI webhook delivery. Node 18+, no dependencies.
//
//   GAI_WEBHOOK_SECRET=whsec_... node verify-signature.mjs      # a minimal receiver on http://localhost:8080/webhooks
//
// Every delivery is a POST with a JSON body and these headers:
//   x-gai-event-id     the event id (the same on every attempt: use it to drop duplicates)
//   x-gai-event-type   order.confirmed | support.requested | webhook.ping
//   x-gai-attempt      1, 2, ... (each attempt is signed again)
//   x-gai-timestamp    Unix seconds when this attempt was signed
//   x-gai-signature    "sha256=" + hex HMAC-SHA256(secret, `${x-gai-timestamp}.${raw body}`)
//
// The secret is the endpoint's signing secret exactly as the portal showed it, prefix included ("whsec_...").
// Verify on the RAW bytes of the body: parsing and re-serialising the JSON changes them.

import crypto from 'node:crypto';
import http from 'node:http';
import { fileURLToPath } from 'node:url';

/** Our own deliveries are signed at send time; five minutes covers clock drift and slow networks. */
export const TOLERANCE_SECONDS = 300;

const header = (headers, name) => {
  for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return Array.isArray(v) ? v[0] : v;
  return undefined;
};

/**
 * @param {Buffer|string} rawBody  the request body exactly as received
 * @param {Record<string, string|string[]|undefined>} headers  the request headers (any case)
 * @param {string} secret  the endpoint's signing secret, "whsec_..." included
 * @returns {{ ok: true } | { ok: false, reason: 'missing_headers' | 'bad_timestamp' | 'timestamp_out_of_tolerance' | 'signature_mismatch' }}
 */
export function verifyGroceryAiSignature(rawBody, headers, secret, { toleranceSeconds = TOLERANCE_SECONDS, now = Date.now() } = {}) {
  const timestamp = header(headers, 'x-gai-timestamp');
  const signature = header(headers, 'x-gai-signature');
  if (!timestamp || !signature) return { ok: false, reason: 'missing_headers' };
  if (!/^\d+$/.test(timestamp)) return { ok: false, reason: 'bad_timestamp' };
  // The timestamp is inside the signed string: an old delivery replayed later fails here, even with a valid signature.
  if (Math.abs(Math.floor(now / 1000) - Number(timestamp)) > toleranceSeconds) return { ok: false, reason: 'timestamp_out_of_tolerance' };
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(String(signature));
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { ok: false, reason: 'signature_mismatch' };
  return { ok: true };
}

// A minimal receiver: verify, drop duplicates, answer 2xx fast, work afterwards.
if (process.argv[1] === fileURLToPath(import.meta.url)) {
  const secret = process.env.GAI_WEBHOOK_SECRET;
  if (!secret) { console.error('set GAI_WEBHOOK_SECRET'); process.exit(1); }
  const seen = new Set();   // in production: a table with a unique key on the event id
  http.createServer((req, res) => {
    if (req.method !== 'POST' || req.url !== '/webhooks') { res.writeHead(404).end(); return; }
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      const raw = Buffer.concat(chunks);
      const check = verifyGroceryAiSignature(raw, req.headers, secret);
      if (!check.ok) { res.writeHead(400).end(check.reason); return; }
      const event = JSON.parse(raw.toString('utf8'));
      res.writeHead(200).end('ok');   // within 10 seconds, or the attempt counts as failed
      if (seen.has(event.id)) return;
      seen.add(event.id);
      if (event.type === 'order.confirmed') console.log('order', event.data.order.id, event.environment);
      else if (event.type === 'support.requested') console.log('support request', event.data.support_request.id, event.data.support_request.kind);
      else if (event.type === 'webhook.ping') console.log('ping for endpoint', event.data.endpoint_id);
    });
  }).listen(Number(process.env.PORT ?? 8080));
}

Signature verification (Python)

verify_signature.py · python

"""Verify a GroceryAI webhook delivery. Python 3.8+, standard library only.

Every delivery is a POST with a JSON body and these headers:
  x-gai-event-id     the event id (the same on every attempt: use it to drop duplicates)
  x-gai-event-type   order.confirmed | support.requested | webhook.ping
  x-gai-attempt      1, 2, ... (each attempt is signed again)
  x-gai-timestamp    Unix seconds when this attempt was signed
  x-gai-signature    "sha256=" + hex HMAC-SHA256(secret, f"{x-gai-timestamp}.{raw body}")

The secret is the endpoint's signing secret exactly as the portal showed it, prefix included ("whsec_...").
Verify on the RAW bytes of the body: parsing and re-serialising the JSON changes them.

With Flask, for example:

    @app.post("/webhooks")
    def webhooks():
        ok, reason = verify_signature(request.get_data(), request.headers, os.environ["GAI_WEBHOOK_SECRET"])
        if not ok:
            return reason, 400
        event = request.get_json()
        # drop duplicates by event["id"], answer within 10 seconds, work afterwards
        return "ok", 200
"""

import hashlib
import hmac
import time

# Our own deliveries are signed at send time; five minutes covers clock drift and slow networks.
TOLERANCE_SECONDS = 300


def verify_signature(raw_body, headers, secret, tolerance_seconds=TOLERANCE_SECONDS, now=None):
    """Return (True, "ok") or (False, reason).

    reason is one of: missing_headers, bad_timestamp, timestamp_out_of_tolerance, signature_mismatch.
    raw_body: bytes exactly as received; headers: any mapping (any case); secret: "whsec_..." included.
    """
    lowered = {str(k).lower(): v for k, v in dict(headers).items()}
    timestamp = lowered.get("x-gai-timestamp")
    signature = lowered.get("x-gai-signature")
    if not timestamp or not signature:
        return False, "missing_headers"
    if not str(timestamp).isdigit():
        return False, "bad_timestamp"
    current = int(time.time()) if now is None else int(now)
    # The timestamp is inside the signed string: an old delivery replayed later fails here, even with a valid signature.
    if abs(current - int(timestamp)) > tolerance_seconds:
        return False, "timestamp_out_of_tolerance"
    if isinstance(raw_body, str):
        raw_body = raw_body.encode("utf-8")
    digest = hmac.new(secret.encode("utf-8"), str(timestamp).encode("ascii") + b"." + raw_body, hashlib.sha256).hexdigest()
    if not hmac.compare_digest("sha256=" + digest, str(signature)):
        return False, "signature_mismatch"
    return True, "ok"

Pull and acknowledge (Node)

pull-orders.mjs · javascript

// Take orders and support requests by pull, then acknowledge them. Node 18+, no dependencies.
//
//   GAI_API_KEY=gai_test_... node pull-orders.mjs
//
// The key needs `orders:read` (to list) and `orders:write` (to acknowledge). Its environment decides what you see:
// a `test` key sees test orders only, a `live` key real ones only. An item stays in `status=new` until you acknowledge
// it, so a crash between "saved" and "acknowledged" only means you will see it again: store by id, idempotently.

import { fileURLToPath } from 'node:url';

const BASE = process.env.GAI_API_BASE ?? 'https://oxjqgjbiltoviwldailg.supabase.co/functions/v1/core-api';
const KEY = process.env.GAI_API_KEY;

const call = async (method, path, idempotencyKey) => {
  // `x-region` runs the platform next to its database (Frankfurt): optional, it only saves latency.
  const headers = { authorization: `Bearer ${KEY}`, 'x-region': 'eu-central-1' };
  if (idempotencyKey) headers['idempotency-key'] = idempotencyKey;
  const res = await fetch(`${BASE}${path}`, { method, headers });
  const body = await res.json();
  if (res.status === 429) {
    const wait = Number(res.headers.get('retry-after') ?? '60');
    await new Promise((r) => setTimeout(r, wait * 1000));
    return call(method, path, idempotencyKey);
  }
  if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${body.error?.code} (request_id ${body.request_id})`);
  return body;
};

/** Your side: save the item. Must be idempotent on the id (the same item can come twice). */
const save = async (kind, id, payload) => {
  console.log(`saved ${kind} ${id}`, kind === 'order' ? `${payload.order.lines.length} lines` : payload.support_request.kind);
};

export const pullOnce = async () => {
  let handled = 0;
  const done = new Set();
  for (const [kind, list, idField, ack] of [
    ['order', '/v1/orders', 'order_id', (id) => `/v1/orders/${encodeURIComponent(id)}/ack`],
    ['support request', '/v1/support_requests', 'support_id', (id) => `/v1/support_requests/${encodeURIComponent(id)}/ack`],
  ]) {
    // Oldest first, up to 200 per call; acknowledging is what moves the queue forward.
    for (;;) {
      const page = await call('GET', `${list}?status=new&limit=50`);
      if (page.data.length === 0) break;
      for (const item of page.data) {
        // An item acknowledged in this run must not come back: if it does, stop instead of looping.
        if (done.has(item.event_id)) throw new Error(`${kind} ${item[idField]} is still new after its ack`);
        done.add(item.event_id);
        await save(kind, item[idField], item.payload);
        // Acknowledging twice is not an error; the idempotency key makes a retried call harmless.
        await call('POST', ack(item[idField]), `ack-${item.event_id}`);
        handled++;
      }
    }
  }
  return handled;
};

if (process.argv[1] === fileURLToPath(import.meta.url)) {
  if (!KEY) { console.error('set GAI_API_KEY'); process.exit(1); }
  pullOnce().then((n) => console.log(`done: ${n} acknowledged`)).catch((e) => { console.error(e.message); process.exit(1); });
}