Documentazione API

Il riferimento OpenAPI 3.1 verificato contro il codice che serve, le guide per chi integra e gli esempi provati.

Versione 1.0.0

Indirizzo dell'API

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

In questa pagina

API GroceryAI · guida per chi integra

Il riferimento completo, rotta per rotta, è il documento OpenAPI 3.1 (openapi.json): forme delle risposte, codici d'errore per ogni stato (x-error-codes) ed eventi (webhooks). Questa guida spiega le regole che valgono per tutte le rotte. Gli esempi in examples/ sono provati a ogni giro contro il codice della piattaforma.

Indirizzo: https://oxjqgjbiltoviwldailg.supabase.co/functions/v1/core-api più il percorso (/v1/products…). Ogni risposta porta x-request-id (lo stesso valore è request_id nel corpo) e x-core-version: se ci scrivi, mandaci il request_id.

1. Chiavi e autenticazione

Ogni chiamata porta Authorization: Bearer <chiave>. La chiave si crea nel portale (Sviluppatori → Chiavi API) e si vede una volta sola: copiala subito in un posto sicuro.

formagai_<ambiente>_<prefisso di 8>_<segreto di 32>, per esempio gai_test_ab12cd34_…
prefissogai_test_ab12cd34 è pubblico: lo vedi nel portale e nel registro attività, da solo non apre niente
ambientetest o live. Separa i dati, non solo l'etichetta: una chiave test vede solo il catalogo, i clienti e gli ordini di prova, una live solo quelli veri
permessicatalog:read, catalog:write, customers:read, customers:write, orders:read, orders:write. Nessuno ne contiene un altro: catalog:write non permette di leggere il catalogo
chiave pubblicabilegai_pk_… serve alla chat sul tuo sito, non ha permessi: su questa API riceve 403 AMBITO_MANCANTE

Una chiave che non apre risponde 401 con un codice preciso: CHIAVE_ASSENTE, CHIAVE_MALFORMATA, CHIAVE_SCONOSCIUTA, CHIAVE_REVOCATA, CHIAVE_SCADUTA, CHIAVE_INCOERENTE. Un negozio non attivo risponde 403 TENANT_SOSPESO; una chiave senza il permesso della rotta 403 AMBITO_MANCANTE.

2. Errori

Un solo formato, su ogni rotta e per ogni errore, nostro o tuo:

{ "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 è ciò su cui programmi: i valori possibili di ogni rotta e stato sono in x-error-codes nel documento.
  • type è la famiglia, e dipende solo dallo stato HTTP: 400 e 405 richiesta_non_valida, 401 autenticazione, 403 autorizzazione, 404 non_trovato, 409 conflitto, 413 troppo_grande, 429 limite_richieste, 503 servizio_non_disponibile, altri 5xx errore_interno.
  • message è per chi legge e può cambiare; param c'è quando l'errore riguarda un campo o un parametro.
  • Un percorso che non esiste è 404 ROTTA_SCONOSCIUTA; un percorso che esiste con il metodo sbagliato è 405 METODO_NON_AMMESSO. La chiave si controlla prima del percorso: senza una chiave valida ricevi il 401.
  • Un 5xx non porta mai il dettaglio interno: il request_id basta a noi per ritrovarlo.

3. Idempotenza

Ogni chiamata che scrive (PUT /v1/products, PUT /v1/inventory, PUT /v1/customers, i POST …/ack) vuole l'intestazione Idempotency-Key: un valore tuo, al più 255 caratteri. Senza: 400 IDEMPOTENCY_KEY_MANCANTE.

  • Stessa chiave, stesso corpo: ricevi la stessa risposta (stato, corpo e request_id della prima volta) con idempotent-replayed: true, e niente viene rieseguito. Puoi quindi ripetere senza paura una chiamata di cui non hai visto la risposta (rete caduta, tempo scaduto).
  • Stessa chiave, corpo diverso: 409 IDEMPOTENCY_KEY_RIUSATA.
  • Stessa chiave mentre la prima chiamata è ancora in corso: 409 RICHIESTA_IN_CORSO; riprova fra qualche secondo.
  • La chiave vale per negozio (su tutte le sue chiavi API e nei due ambienti) e non scade: usa un valore nuovo per ogni operazione distinta (un UUID, o un id del tuo sistema come ack-<event_id>).
  • Viene registrato anche l'esito di una chiamata fallita dopo essere stata accettata (un 404 sull'ack di un ordine che non esiste, un 5xx): ripeterla con la stessa chiave ti ridà quell'esito, marcato idempotent-replayed. Per riprovare davvero serve una chiave nuova. Gli errori che arrivano prima (chiave API, permessi, limite, corpo non JSON o troppo grande, Idempotency-Key mancante o troppo lunga) non consumano la chiave.

4. Limiti

richiesteper chiave API, a finestra di un minuto (120 al minuto se non diverso per quella chiave). Oltre: 429 LIMITE_RICHIESTE con Retry-After = secondi alla fine della finestra
corpoal più 1 MiB: oltre, 413 CORPO_TROPPO_GRANDE
lottial più 500 elementi per chiamata: oltre, 400 LOTTO_TROPPO_GRANDE; una lista vuota è 400 LOTTO_VUOTO

Nei lotti un elemento non valido non fa cadere il lotto: la risposta dice quanti ne sono arrivati, quanti scritti e quali rifiutati, con posizione e motivo.

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

index è la posizione nel tuo lotto (da 0); è null quando a rifiutare è stato il database dopo la validazione (RIFIUTATA_DAL_DATABASE). Su PUT /v1/inventory uno SKU che non esiste è SKU_SCONOSCIUTO: non si crea un prodotto a metà. I prezzi si caricano nella valuta del negozio: un'altra valuta è VALUTA_DIVERSA_DAL_TENANT, non si converte.

5. Paginazione

  • Catalogo (GET /v1/products): ordinato per sku; limit da 1 a 200 (50 se manca); la pagina successiva si chiede con cursor = next_cursor della precedente. next_cursor c'è solo quando la pagina è piena: l'ultima pagina piena porta quindi a una chiamata in più che torna vuota. Filtri: sku, gtin.
  • Ordini e richieste di assistenza (GET /v1/orders, GET /v1/support_requests): dal più vecchio, limit da 1 a
  1. Non c'è cursore: la coda avanza con l'ack. status=new (predefinito) dà ciò che non hai ancora confermato, status=acked ciò che hai confermato.

6. Eventi via webhook

Quando un cliente conferma un ordine (order.confirmed) o chiede una persona dello staff (support.requested), la piattaforma manda una POST firmata a ogni endpoint del negozio iscritto a quell'evento. Il pulsante «Manda un evento di prova» del portale manda un webhook.ping (ambiente test) al solo endpoint provato.

Il corpo è sempre la stessa busta:

{ "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 cambia con il tipo; le forme esatte sono nel documento (webhooks, schemi OrdineConfermato, RichiestaAssistenza, Ping). environment dice se è una prova o un fatto vero: gli endpoint sono gli stessi.

Verificare la firma

Ogni consegna porta:

intestazione
x-gai-event-idl'id dell'evento, uguale a ogni tentativo
x-gai-event-typeil tipo
x-gai-attemptil numero del tentativo, da 1
x-gai-timestampi secondi Unix dell'istante della firma
x-gai-signaturesha256= + HMAC-SHA256 esadecimale di <x-gai-timestamp>.<corpo grezzo>
  1. Prendi il corpo grezzo, i byte esatti ricevuti: rileggere e riscrivere il JSON cambia i byte e la firma non torna.
  2. Calcola l'HMAC-SHA256 di <x-gai-timestamp> + . + corpo, con chiave il segreto di firma dell'endpoint per intero, prefisso whsec_ compreso, così come il portale te l'ha mostrato alla creazione.
  3. Confronta sha256=<esadecimale> con x-gai-signature a tempo costante.
  4. Rifiuta se x-gai-timestamp dista più di 300 secondi dal tuo orologio: una consegna vera intercettata e rimandata più tardi cade qui, anche con la firma giusta.

Esempi completi e provati: examples/verify-signature.mjs (Node) e examples/verify_signature.py (Python).

Rispondere e ritentare

  • Rispondi 2xx entro 10 secondi e lavora dopo. Qualunque altra risposta, o nessuna risposta in tempo, è un tentativo fallito.
  • I tentativi sono sei: il primo entro un minuto circa dall'evento, poi ciascuno dopo il precedente di 1 minuto, 5 minuti, 30 minuti, 2 ore e 6 ore (circa 8 ore e mezza in tutto). Dopo il sesto la consegna è abbandonata e lo vedi nel portale (Sviluppatori → Webhook → Consegne), da dove puoi rimandarla.
  • Ogni tentativo è firmato di nuovo, con il suo x-gai-timestamp e il suo attempt; id e data non cambiano.
  • Lo stesso evento può arrivarti più di una volta (un ritento dopo un 2xx che non ci è arrivato, una consegna rimandata dal portale): scarta i doppioni con l'id.
  • Il segreto si vede una volta sola, alla creazione dell'endpoint. Un endpoint disattivato e poi riattivato riceve un segreto nuovo.

7. Ritiro in pull

Se non vuoi un endpoint in ingresso, ritiri ordini e richieste quando vuoi tu: GET /v1/orders?status=new (permesso orders:read), salvi, e confermi con POST /v1/orders/{order_id}/ack (permesso orders:write, con Idempotency-Key). Lo stesso per GET /v1/support_requests e POST /v1/support_requests/{support_id}/ack.

  • Vedi solo ciò che appartiene all'ambiente della tua chiave.
  • Ogni elemento porta event_id (lo stesso id del webhook), l'identificativo da passare all'ack, payload (lo stesso data del webhook), occurred_at e created_at, e stato (in_attesa finché non confermi, poi consegnato con consegnato_at).
  • Confermare due volte non è un errore. Finché non confermi, l'elemento torna a ogni lettura: salva per id, in modo idempotente, e se cadi fra il salvataggio e la conferma lo rivedrai, senza perderlo.
  • Gli ordini più vecchi dell'11/10/2026 sono nella versione 1 del contratto: niente schema_version, e il cliente ha il solo chatId.

Esempio completo e provato: examples/pull-orders.mjs.

Riferimento delle rotte

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.

Autenticazione

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.

Permesso: basta una chiave segreta valida, senza un permesso particolare.

Risposte

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.

Permesso: catalog:write

Parametri

  • Idempotency-Keyintestazionestringobbligatorio

    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

Corpo della richiestaobbligatorio

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

Risposte

GET/v1/products

Elenca il catalogo del tenant

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

Permesso: catalog:read

Parametri

  • skunella querystring

    Un solo prodotto, per SKU.

  • gtinnella querystring

    I prodotti con questo GTIN.

  • limitnella queryinteger

    Da 1 a 200 (default 50).

  • cursornella querystring

    Ultimo sku della pagina precedente.

Risposte

  • 200La pagina, con next_cursor quando c'e' altro.

    object

    • dataarray<Prodotto>obbligatorio

    • next_cursorstring | nullobbligatorio

      L'ultimo sku della pagina, solo se la pagina e' piena: si passa come cursor.

    • request_idRequestIdobbligatorio

  • 400Richiesta non valida: il code dice quale campo o intestazione, param dove.

    Codici d'errore

    PARAMETRO_NON_VALIDO

  • 401Chiave assente, malformata, sconosciuta, revocata o scaduta.

    Codici d'errore

    CHIAVE_ASSENTECHIAVE_MALFORMATACHIAVE_SCONOSCIUTACHIAVE_REVOCATACHIAVE_SCADUTACHIAVE_INCOERENTE

  • 403La chiave non ha l'ambito richiesto, o il tenant non e' attivo.

    Codici d'errore

    TENANT_SOSPESOAMBITO_MANCANTE

  • 429Superato il limite di richieste al minuto per questa chiave (Retry-After dice quanto aspettare).

    Codici d'errore

    LIMITE_RICHIESTE

  • 500Errore interno: il request_id identifica la chiamata nel nostro log.

    Codici d'errore

    ERRORE_INTERNO

  • 503Servizio non disponibile su questa istanza: il code dice quale pezzo manca. Si riprova piu' tardi.

    Codici d'errore

    DB_URL_ASSENTE

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'.

Permesso: catalog:write

Parametri

  • Idempotency-Keyintestazionestringobbligatorio

    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

Corpo della richiestaobbligatorio

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

Risposte

PUT/v1/customers

Sincronizza i clienti, a lotti

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

Permesso: customers:write

Parametri

  • Idempotency-Keyintestazionestringobbligatorio

    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

Corpo della richiestaobbligatorio

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

Risposte

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.

Permesso: customers:read

Parametri

  • external_refnel percorsostringobbligatorio

    L'identificativo del cliente nel sistema del tenant.

Risposte

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.

Permesso: orders:read

Parametri

  • statusnella querystring

    new (default) oppure acked.

  • limitnella queryinteger

    Da 1 a 200 (default 50).

Risposte

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.

Permesso: orders:write

Parametri

  • order_idnel percorsostringobbligatorio

    L'identificativo dell'ordine.

  • Idempotency-Keyintestazionestringobbligatorio

    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

Corpo della richiesta

Vuoto, oppure {}.

object

Vuoto, oppure {}.

maxProperties: 0

Risposte

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.

Permesso: orders:read

Parametri

  • statusnella querystring

    new (default) oppure acked.

  • limitnella queryinteger

    Da 1 a 200 (default 50).

Risposte

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.

Permesso: orders:write

Parametri

  • support_idnel percorsostringobbligatorio

    L'identificativo della richiesta (support_request.id).

  • Idempotency-Keyintestazionestringobbligatorio

    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

Corpo della richiesta

Vuoto, oppure {}.

object

Vuoto, oppure {}.

maxProperties: 0

Risposte

Errori del percorso, su ogni rotta

  • 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).

Intestazioni delle risposte

  • 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.

Eventi dei webhook

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.

Intestazioni

  • x-gai-event-idintestazionestringobbligatorio

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

    format: uuid

  • x-gai-event-typeintestazionestringobbligatorio

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

  • x-gai-attemptintestazionestringobbligatorio

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

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

  • x-gai-timestampintestazionestringobbligatorio

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

    pattern: ^[0-9]+$

  • x-gai-signatureintestazionestringobbligatorio

    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-agentintestazionestringobbligatorio

    Sempre GroceryAI-Core-Events/1.

Corpo dell'evento

object

  • idstringobbligatorio

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

    format: uuid

  • typestringobbligatorio

    Valori: "order.confirmed"

  • environmentstringobbligatorio

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

    Valori: "test""live"

  • occurred_atstringobbligatorio

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

    format: date-time

  • resource_idstringobbligatorio

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

  • attemptintegerobbligatorio

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

    minimum: 1

  • dataOrdineConfermatoobbligatorio

Risposte

  • 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.

Intestazioni

  • x-gai-event-idintestazionestringobbligatorio

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

    format: uuid

  • x-gai-event-typeintestazionestringobbligatorio

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

  • x-gai-attemptintestazionestringobbligatorio

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

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

  • x-gai-timestampintestazionestringobbligatorio

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

    pattern: ^[0-9]+$

  • x-gai-signatureintestazionestringobbligatorio

    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-agentintestazionestringobbligatorio

    Sempre GroceryAI-Core-Events/1.

Corpo dell'evento

object

  • idstringobbligatorio

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

    format: uuid

  • typestringobbligatorio

    Valori: "support.requested"

  • environmentstringobbligatorio

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

    Valori: "test""live"

  • occurred_atstringobbligatorio

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

    format: date-time

  • resource_idstringobbligatorio

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

  • attemptintegerobbligatorio

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

    minimum: 1

  • dataRichiestaAssistenzaobbligatorio

Risposte

  • 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.

Intestazioni

  • x-gai-event-idintestazionestringobbligatorio

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

    format: uuid

  • x-gai-event-typeintestazionestringobbligatorio

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

  • x-gai-attemptintestazionestringobbligatorio

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

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

  • x-gai-timestampintestazionestringobbligatorio

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

    pattern: ^[0-9]+$

  • x-gai-signatureintestazionestringobbligatorio

    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-agentintestazionestringobbligatorio

    Sempre GroceryAI-Core-Events/1.

Corpo dell'evento

object

  • idstringobbligatorio

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

    format: uuid

  • typestringobbligatorio

    Valori: "webhook.ping"

  • environmentstringobbligatorio

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

    Valori: "test"

  • occurred_atstringobbligatorio

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

    format: date-time

  • resource_idnullobbligatorio

  • attemptintegerobbligatorio

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

    minimum: 1

  • dataPingobbligatorio

Risposte

  • 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.

Consegna degli eventi

  • Ritenti, in secondi dopo il tentativo precedente: 60, 300, 1800, 7200, 21600
  • Tentativi al massimo: 6
  • Tempo per rispondere: 10000 ms
  • Firma: HMAC-SHA256(segreto, ${x-gai-timestamp}.${corpo grezzo}), esadecimale, preceduto da sha256=

Schemi

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

  • errorobjectobbligatorio

    • typestringobbligatorio

      La famiglia dell'errore, derivata dallo stato HTTP.

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

    • codestringobbligatorio

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

    • messagestringobbligatorio

      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_idRequestIdobbligatorio

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.

  • skustringobbligatorio

    minLength: 1maxLength: 128

  • titlestringobbligatorio

    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).

Almeno uno fra: pricenet_pricestock_qtyavailable

  • skustringobbligatorio

    minLength: 1

  • pricenumber

    minimum: 0

  • net_pricenumber

    minimum: 0

  • stock_qtynumber

    minimum: 0

  • availableboolean

ClienteInIngresso

object

  • external_refstringobbligatorio

    L'identificativo del cliente nel sistema del tenant.

    minLength: 1maxLength: 128

  • display_namestring

  • emailstring

  • phonestring

  • addressobject

  • attributesobject

EsitoLottoProdotti

object

  • receivedintegerobbligatorio

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerobbligatorio

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerobbligatorio

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>obbligatorio

    • indexinteger | nullobbligatorio

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

    • idstring | nullobbligatorio

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

    • codestringobbligatorio

      Valori: "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"

    • messagestringobbligatorio

  • request_idRequestIdobbligatorio

EsitoLottoGiacenze

object

  • receivedintegerobbligatorio

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerobbligatorio

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerobbligatorio

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>obbligatorio

    • indexinteger | nullobbligatorio

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

    • idstring | nullobbligatorio

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

    • codestringobbligatorio

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

    • messagestringobbligatorio

  • request_idRequestIdobbligatorio

EsitoLottoClienti

object

  • receivedintegerobbligatorio

    Quanti elementi sono arrivati.

    minimum: 0

  • writtenintegerobbligatorio

    Quanti sono stati scritti.

    minimum: 0

  • rejectedintegerobbligatorio

    Quanti sono stati rifiutati: la lunghezza di errors.

    minimum: 0

  • errorsarray<object>obbligatorio

    • indexinteger | nullobbligatorio

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

    • idstring | nullobbligatorio

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

    • codestringobbligatorio

      Valori: "ELEMENTO_NON_OGGETTO""EXTERNAL_REF_MANCANTE""EXTERNAL_REF_TROPPO_LUNGO""EMAIL_NON_VALIDA""RIFIUTATA_DAL_DATABASE"

    • messagestringobbligatorio

  • request_idRequestIdobbligatorio

Prodotto

object

  • skustringobbligatorio

  • gtinstring | nullobbligatorio

  • titlestringobbligatorio

  • brandstring | nullobbligatorio

  • categorystring | nullobbligatorio

  • unitstring | nullobbligatorio

  • pricenumber | nullobbligatorio

  • net_pricenumber | nullobbligatorio

  • currencystringobbligatorio

  • stock_qtynumber | nullobbligatorio

  • availablebooleanobbligatorio

  • linkstring | nullobbligatorio

  • image_linkstring | nullobbligatorio

  • attributesobjectobbligatorio

Cliente

object

  • external_refstringobbligatorio

  • display_namestring | nullobbligatorio

  • emailstring | nullobbligatorio

  • phonestring | nullobbligatorio

  • addressobjectobbligatorio

  • attributesobjectobbligatorio

OrdineDaRitirare

object

  • event_idstringobbligatorio

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

    format: uuid

  • order_idstringobbligatorio

    Si passa all'ack.

  • typestringobbligatorio

    Valori: "order.confirmed"

  • payloadOrdineConfermatoobbligatorio

  • occurred_atstringobbligatorio

    format: date-time

  • created_atstringobbligatorio

    format: date-time

  • statostringobbligatorio

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

    Valori: "in_attesa""consegnato"

  • consegnato_atstring | nullobbligatorio

    L'istante del primo ack.

    format: date-time

RichiestaDaRitirare

object

  • support_idstringobbligatorio

    Si passa all'ack.

  • event_idstringobbligatorio

    format: uuid

  • typestringobbligatorio

    Valori: "support.requested"

  • payloadRichiestaAssistenzaobbligatorio

  • occurred_atstringobbligatorio

    format: date-time

  • created_atstringobbligatorio

    format: date-time

  • statostringobbligatorio

    Valori: "in_attesa""consegnato"

  • consegnato_atstring | nullobbligatorio

    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.

  • chatIdstringobbligatorio

    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

    Valori: 2

  • orderobjectobbligatorio

    • idstringobbligatorio

    • statusstringobbligatorio

      Valori: "confirmed"

    • linesarray<object>obbligatorio

      • skustring | nullobbligatorio

      • namestringobbligatorio

      • brandstring | nullobbligatorio

      • qtynumberobbligatorio

      • unitstring | nullobbligatorio

      • unit_pricenumber | nullobbligatorio

        null vuol dire «da reperire», non zero.

      • out_of_catalogbooleanobbligatorio

    • totalsobjectobbligatorio

      Importi nella valuta del tenant, calcolati dal database.

      • subtotalnumber | null

      • discountsnumber | null

      • deliverynumber | null

      • extrasnumber | null

      • totalnumber | null

    • deliveryobjectobbligatorio

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

      • startISOstring

        format: date-time

      • endISOstring

        format: date-time

      • labelstring

    • customerClienteDellEventoobbligatorio

    • notesstring | nullobbligatorio

      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_versionintegerobbligatorio

    Valori: 1

  • support_requestobjectobbligatorio

    • idstringobbligatorio

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

    • kindstringobbligatorio

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

    • outcomestringobbligatorio

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

      Valori: "nuova""cumulativa""escalation"

    • customer_messagestring | nullobbligatorio

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

    • langstring | nullobbligatorio

    • channelstringobbligatorio

    • order_codestring | nullobbligatorio

    • customerClienteDellEventoobbligatorio

Ping

object

  • testbooleanobbligatorio

    Valori: true

  • endpoint_idintegerobbligatorio

    L'endpoint provato.

Esempi provati

Verifica della firma (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));
}

Verifica della firma (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"

Ritiro in pull e conferma (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); });
}