API v1
Documentazione dell'API dei richiami alimentari
API REST in JSON con i richiami alimentari pubblicati dal Ministero della Salute: campi separati e normalizzati, link all'avviso ufficiale, revoche, liste di sorveglianza per le aziende tue clienti e webhook firmati. Indirizzo base: https://richiamialimenti.it/api/v1. Specifica OpenAPI 3.1: /api/v1/openapi.json.
Inizio rapido
1. Crea un'azienda per ogni attività tua cliente e conserva il suo id:
curl -X POST https://richiamialimenti.it/api/v1/companies \
-H "Authorization: Bearer $RICHIAMI_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Trattoria da Mario srl", "vat_number": "IT01234567890", "external_id": "CLI-042"}'
2. Leggi i richiami indicando l'azienda nell'header X-Company-Id:
curl "https://richiamialimenti.it/api/v1/recalls?per_page=20" \
-H "Authorization: Bearer $RICHIAMI_KEY" \
-H "X-Company-Id: 3f0c8a2e-5b1d-4c7a-9e8f-2a6b4d1c0e93"
3. Aggiungi alla lista di sorveglianza dell'azienda i prodotti, i marchi o i fornitori da controllare, e imposta il webhook per sapere subito quando un richiamo li tocca:
curl -X POST https://richiamialimenti.it/api/v1/companies/3f0c8a2e-5b1d-4c7a-9e8f-2a6b4d1c0e93/watchlist \
-H "Authorization: Bearer $RICHIAMI_KEY" -H "Content-Type: application/json" \
-d '{"items": [{"brand": "Salumi Rossi", "lot": "L24A123"}, {"gtin": "8001234567897"}]}'
curl -X PUT https://richiamialimenti.it/api/v1/webhook \
-H "Authorization: Bearer $RICHIAMI_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://tuo-server.example/webhook/richiami"}'
Autenticazione e prova
Ogni chiamata (tranne la specifica OpenAPI) porta la chiave personale nell'header Authorization: Bearer rk_live_…. La chiave te la consegniamo una volta sola e la conserviamo solo in forma cifrata (hash): se la perdi la revochiamo e te ne diamo una nuova. Tienila sul server, mai in un'app o in una pagina web.
Durante la prova gratuita di 30 giorni ogni risposta ha gli header X-Richiami-Plan: trial e X-Richiami-Trial-Ends con la data di fine. Alla scadenza l'accesso si chiude da solo con l'errore trial_expired: non ci sono addebiti né rinnovi automatici. GET /account riporta stato, fine della prova, limiti e numero di aziende.
Aziende e X-Company-Id
Le aziende sono le attività alimentari tue clienti. Crei un'azienda per ogni cliente finale reale con POST /companies: ragione sociale e partita IVA o codice fiscale sono obbligatori. Controlliamo il carattere di controllo e, in un secondo momento, la partita IVA su VIES (il risultato è in vat_check). Ogni partita IVA o codice fiscale vale per una sola azienda dello stesso cliente e non si può cambiare dopo la creazione: per un nuovo cliente finale crea una nuova azienda.
Le chiamate ai dati (/recalls, /matches) vogliono l'header X-Company-Id con l'id di una tua azienda attiva: così sappiamo per chi è ogni chiamata e i dati dei tuoi clienti restano separati. Le aziende si gestiscono con GET/PATCH/DELETE /companies/{id}; DELETE disattiva l'azienda (i dati restano) e PATCH con "active": true la riattiva. Le aziende create due volte rispondono 409 company_exists con l'id di quella esistente.
Lista di sorveglianza
Ogni azienda ha una lista di voci da tenere d'occhio: GET/POST /companies/{id}/watchlist, PATCH/DELETE /companies/{id}/watchlist/{voce}. Una voce ha uno o più criteri e corrisponde a un richiamo quando corrispondono tutti i suoi criteri: «marchio X» trova ogni richiamo del marchio, «marchio X + lotto Y» solo quel lotto. Puoi aggiungere fino a 500 voci in una chiamata ({"items": [...]}) e fino a 1.000 per azienda; label ed external_id (il tuo codice articolo) tornano negli abbinamenti.
| Criterio | Quando corrisponde |
|---|---|
| brand | Stesso marchio del nostro catalogo (anche con un nome diverso, es. «Zero+4 S.r.l.» e «ZERO+4»), oppure il nome come parole intere nel marchio dell'avviso. |
| producer | Stessa azienda del catalogo, oppure il nome senza forma societaria (srl, spa…) nel produttore dell'avviso. |
| product | Tutte le parole indicate nel nome del prodotto; singolare e plurale valgono uguale («mozzarelle» trova «mozzarella»). Indica solo le parole che identificano il prodotto, non il formato. |
| gtin | Uno dei codici a barre del richiamo (8, 12, 13 o 14 cifre, con cifra di controllo corretta). |
| lot | Uno dei codici di lotto dell'avviso, ignorando spazi e punteggiatura («L 24/A-123» = «L24A123»). Almeno 3 lettere o cifre. |
| supplier | Il nome nel distributore, nel produttore o nella catena del marchio: utile per i grossisti e le catene da cui compra l'attività. |
La risposta di POST elenca per ogni voce, in matches, gli id dei richiami già in archivio che la toccano: utile per controllare subito i prodotti che l'attività ha in magazzino. Questi abbinamenti storici non mandano webhook.
Richiami e sincronizzazione
GET /recalls restituisce solo richiami attivi, al massimo 200 per pagina (per_page, default 50). Senza updated_since sono ordinati dal più recente; con updated_since sono in ordine di modifica, dal meno recente. Filtri: brand (slug o testo), producer, product, lot, gtin (completo), hazard (slug del motivo), chain (slug della catena), revoked, from e to (data del richiamo). GET /recalls/{id} restituisce un richiamo.
Paginazione
La paginazione è a cursore: se meta.next_cursor non è null, chiedi la pagina successiva con cursor=<valore> e gli stessi filtri. Il cursore resta valido anche se nel frattempo arrivano nuovi richiami.
Tenere una copia aggiornata
La prima volta scarica tutto l'archivio pagina per pagina. Poi, ogni ora o quando ricevi un webhook, chiedi GET /recalls?updated_since=<ultimo meta.last_update> e aggiorna i record per id; togli quelli elencati da GET /recalls/removed?since=…. In ogni risposta meta.last_update è l'ultima modifica di un record del feed e meta.last_check l'ultimo controllo degli avvisi del Ministero (circa ogni 2 ore): mostrali ai tuoi utenti come «dati aggiornati al».
{
"data": [ { "id": 1950, "product": "…", "updated_at": "2026-10-23T10:12:40+02:00", "…": "…" } ],
"meta": {
"per_page": 50,
"next_cursor": "WyIyMDI2LTEwLTIzIDEwOjEyOjQwIiwxOTUwXQ",
"sort": "updated_at",
"last_update": "2026-10-23T10:12:40+02:00",
"last_check": "2026-10-24T08:00:03+02:00"
}
}
Formato del record
Un richiamo ha sempre gli stessi campi; quelli che l'avviso non riporta valgono null o lista vuota. Il codice GTIN c'è solo quando l'operatore lo scrive nell'avviso o quando il codice a barre è leggibile nelle foto: il modulo del Ministero non lo prevede, quindi manca nella maggior parte dei richiami. Nel feed non ci sono foto delle confezioni: per immagini e testo integrale vale l'avviso ufficiale.
| Campo | Contenuto |
|---|---|
| id | Id stabile del richiamo. |
| url | Pagina del richiamo su richiamialimenti.it. |
| recall_date | Data del richiamo (AAAA-MM-GG). |
| updated_at | Ultima modifica di un campo di questo record. Non cambia per lavori interni (foto, controlli). |
| product | Nome del prodotto come nell'avviso. |
| brand / brand_raw | Marchio normalizzato del nostro catalogo ({name, slug}) e marchio come scritto nell'avviso. |
| company / producer_raw | Produttore normalizzato ({name, city}) e produttore come scritto nell'avviso. |
| private_label_of | Catena di cui il marchio è marca del distributore, se lo è. |
| hazards | Motivi normalizzati (slug, nome, categoria), es. listeria, allergeni. |
| reason / warnings | Motivo e avvertenze come nell'avviso. |
| lot / lot_codes | Lotti come nell'avviso e codici di lotto estratti (lettere e cifre maiuscole, senza spazi). |
| expiry / expiry_dates | Scadenza o TMC come nell'avviso e date leggibili in ISO 8601: AAAA-MM-GG, oppure AAAA-MM se l'avviso indica solo mese e anno. |
| plant / format / distributor | Stabilimento o bollo CE, formato, distributore. |
| gtin / gtin_source | Codici GTIN/EAN con cifra di controllo corretta e loro origine: avviso (scritto nel testo), foto (letto dal codice a barre nelle foto), manuale (inserito da noi). |
| revoked / revocation | Se il richiamo è stato revocato, con data, motivo e link all'avviso di revoca. |
| official_notice | page_url: scheda dell'avviso sul sito del Ministero; pdf_url: PDF ufficiale. |
| source | Fonte, licenza (CC BY 4.0) e testo di attribuzione da mostrare. |
L'ultimo richiamo pubblicato
{
"id": 1898,
"url": "https://richiamialimenti.it/richiami/1898-tramezzini-coop-prosciutto-cotto",
"recall_date": "2026-12-10",
"updated_at": "2026-10-12T18:01:59+00:00",
"product": "TRAMEZZINI - Coop Prosciutto cotto",
"brand": {
"name": "Coop",
"slug": "coop"
},
"brand_raw": "COOP",
"company": {
"name": "Riva Alimentari Uniti S.r.l.",
"city": "Nibionno"
},
"producer_raw": "Riva Alimentari Uniti Srl",
"private_label_of": "Coop",
"hazards": [
{
"slug": "allergeni",
"name": "Allergeni non dichiarati",
"category": "allergeni"
},
{
"slug": "etichettatura",
"name": "Errori di etichettatura",
"category": "conformita"
}
],
"reason": "Errata etichettatura del prodotto. Il prodotto \"Tramezzino COOP Gamberetti 140g\" è stato erroneamente confezionato ed etichettato come \"Tramezzino COOP Prosciutto cotto 150g\". Presenza allergene CROSTACEI.",
"warnings": "I soggetti allergici a CROSTACEI non devono consumare il prodotto. Restituire al punto vendita.",
"lot": "A-269-F",
"lot_codes": [
"A269F"
],
"expiry": "2026-10-31",
"expiry_dates": [
"2026-10-31"
],
"plant": "UE IT D1T8V",
"format": "150g",
"distributor": "Coop Italia Società Cooperativa",
"gtin": [],
"gtin_source": null,
"revoked": false,
"revocation": null,
"official_notice": {
"page_url": null,
"pdf_url": null
},
"source": {
"name": "Ministero della Salute",
"license": "CC BY 4.0",
"license_url": "https://creativecommons.org/licenses/by/4.0/deed.it",
"attribution": "Fonte: Ministero della Salute, avvisi di richiamo dei prodotti alimentari. Dati elaborati da Richiami Alimenti, non affiliato al Ministero."
}
}
Abbinamenti
GET /matches (con X-Company-Id) restituisce i richiami che toccano la lista di sorveglianza dell'azienda, dal più recente, con il motivo dell'abbinamento: per ogni voce, i criteri che corrispondono e il valore trovato nel richiamo. Filtri: since e revoked. source vale new_recall o recall_updated quando l'abbinamento è arrivato con il feed (e con il webhook match.created), watchlist quando l'abbiamo trovato in archivio al momento in cui hai aggiunto la voce.
{
"matched_at": "2026-10-23T10:15:02+02:00",
"source": "new_recall",
"items": [
{ "watchlist_item_id": 812, "label": "Salame del banco", "external_id": "ART-9",
"matched": { "brand": "Salumi Rossi", "lot": "L24A123" } }
],
"recall": { "id": 1950, "…": "…" }
}
Revoche e rimozioni
Il Ministero pubblica anche le revoche dei richiami (per esempio dopo analisi di revisione favorevoli). Un richiamo revocato resta nel feed con revoked: true e revocation (data e motivo dell'avviso di revoca, link alla sua scheda); arriva il webhook recall.revoked e il record cambia updated_at. Colleghiamo una revoca al richiamo originale solo quando marchio e prodotto coincidono e lotto o scadenza lo confermano; altrimenti la verifichiamo a mano prima di pubblicarla. Filtra con revoked=false per mostrare solo i richiami ancora validi.
Raramente un richiamo viene tolto dal feed: doppioni uniti a un altro record o avvisi ritirati dal Ministero. Arriva il webhook recall.removed, il richiamo compare in GET /recalls/removed e GET /recalls/{id} risponde 410 con replaced_by, l'id del richiamo che lo sostituisce se c'è.
Webhook
Imposta un URL https pubblico sulla porta standard con PUT /webhook (events facoltativo per ricevere solo alcuni eventi). La prima risposta contiene il secret per verificare le firme: conservalo, perché non lo mostriamo più (le altre risposte hanno solo secret_hint, le ultime 4 cifre). POST /webhook/secret ne crea uno nuovo, lo restituisce e invalida subito il precedente. Gli eventi partono pochi minuti dopo che un richiamo è stato importato o modificato, così arrivano completi.
| Evento | Quando | data |
|---|---|---|
| recall.created | Nuovo richiamo (o tornato nel feed). | {recall} |
| recall.updated | È cambiato almeno un campo del record (es. lotto corretto, GTIN aggiunto). | {recall, changed: ["lot", ...]} |
| recall.revoked | Il Ministero ha revocato il richiamo. | {recall} |
| recall.removed | Richiamo tolto dal feed (doppione unito a un altro, avviso ritirato). | {recall_id, replaced_by} |
| match.created | Un richiamo nuovo o aggiornato tocca la lista di sorveglianza di una tua azienda. | {company, matched_at, items, recall} |
Richiesta
Ogni evento è un POST JSON con gli header X-Richiami-Event (tipo di evento), X-Richiami-Delivery (id univoco della consegna, uguale a id nel corpo) e X-Richiami-Signature:
X-Richiami-Signature: t=1792834560,v1=5f2b9c…e41a
{
"id": "0b9f6c64-2f7e-4a55-9a51-3c1c3e1f9d20",
"event": "recall.created",
"created_at": "2026-10-23T10:15:00+02:00",
"api_version": "v1",
"test": false,
"data": { "recall": { "id": 1950, "…": "…" } }
}
Verificare la firma
v1 è l'HMAC-SHA256 in esadecimale, calcolato con il tuo secret su <t>.<corpo della richiesta così come arriva>. Confronta in tempo costante e scarta le richieste con t più vecchio di 5 minuti.
// PHP
$body = file_get_contents('php://input');
preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $_SERVER['HTTP_X_RICHIAMI_SIGNATURE'] ?? '', $m);
$valid = $m && abs(time() - (int) $m[1]) <= 300
&& hash_equals(hash_hmac('sha256', $m[1] . '.' . $body, getenv('RICHIAMI_WEBHOOK_SECRET')), $m[2]);
// Node.js (Express con express.raw({ type: 'application/json' }))
const crypto = require('crypto');
const [, t, v1] = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(req.get('X-Richiami-Signature') || '') || [];
const expected = crypto.createHmac('sha256', process.env.RICHIAMI_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
const valid = t && Math.abs(Date.now() / 1000 - t) <= 300
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
# Python
import hmac, hashlib, re, time
m = re.match(r'^t=(\d+),v1=([0-9a-f]{64})$', request.headers.get('X-Richiami-Signature', ''))
expected = hmac.new(SECRET.encode(), f"{m[1]}.".encode() + request.get_data(), hashlib.sha256).hexdigest() if m else ''
valid = bool(m) and abs(time.time() - int(m[1])) <= 300 and hmac.compare_digest(expected, m[2])
Risposte e nuovi tentativi
Rispondi con un codice 2xx entro 10 secondi, poi elabora l'evento con calma. Se l'endpoint non risponde 2xx riproviamo dopo 1 minuto, 5 minuti, 15 minuti, 1 ora, 3 ore, 6 ore e 12 ore (8 tentativi in circa un giorno); non seguiamo i redirect. Lo stesso evento può arrivare più di una volta e non sempre in ordine: usa X-Richiami-Delivery per scartare i doppioni e updated_at del record per tenere la versione più recente. GET /webhook/deliveries mostra le ultime consegne con esito, tentativi e ultimo errore. In ogni caso il feed con updated_since resta la fonte completa: se il tuo server è stato giù, ti basta rileggere da lì.
Dati di prova e dati reali
Non c'è un ambiente di prova separato: anche durante i 30 giorni gratuiti l'API restituisce i dati reali, gli stessi della produzione, e gli eventi del feed sono richiami veri ("test": false). Sono di prova solo gli eventi che chiedi tu con POST /webhook/test: hanno "test": true e un richiamo finto con id 0 e nome «PRODOTTO DI PROVA - non è un richiamo». Non mostrarli mai ai tuoi utenti. Puoi scegliere il tipo con {"event": "match.created"}; al massimo 5 eventi di prova al minuto e 100 al giorno.
Fonte ufficiale e licenza
I dati vengono dagli avvisi pubblicati dal Ministero della Salute, riutilizzabili con licenza CC BY 4.0 citando la fonte. Quando mostri i dati ai tuoi clienti indica la fonte (il testo è in source.attribution) e, quando c'è, il link all'avviso ufficiale (official_notice.page_url o pdf_url). Richiami Alimenti non è affiliato al Ministero: non presentare i dati come provenienti da noi per conto del Ministero. L'estrazione dai PDF è automatica e può sbagliare: in caso di dubbio vale sempre l'avviso ufficiale, e il servizio non sostituisce le procedure del piano di autocontrollo.
Limiti
Di base: 120 richieste al minuto per chiave e 20.000 al giorno per cliente (i tuoi limiti sono in GET /account). Ogni risposta ha gli header X-RateLimit-Limit e X-RateLimit-Remaining; oltre il limite rispondiamo 429 con Retry-After. Per tenere una copia aggiornata basta una chiamata con updated_since ogni ora, più i webhook.
Errori
Gli errori sono JSON con message (in italiano, da leggere) e code (stabile, da usare nel codice). Gli errori di validazione hanno invece errors con i messaggi per ogni campo (nelle liste: items.3.gtin).
| HTTP | code | Significato |
|---|---|---|
| 400 | company_required | Manca l'header X-Company-Id. |
| 401 | invalid_api_key | Chiave mancante, sbagliata o revocata. |
| 403 | trial_expired | Il periodo di prova è finito. |
| 403 | account_suspended / account_ended | Accesso sospeso o terminato. |
| 403 | company_deactivated | L'azienda indicata è disattivata. |
| 403 | company_limit | Raggiunto il numero massimo di aziende attive. |
| 404 | company_not_found / recall_not_found / watchlist_item_not_found | Non esiste, o non è tra le tue. |
| 409 | company_exists | Hai già un'azienda con questa partita IVA o codice fiscale: l'id è in company_id. |
| 409 | webhook_not_configured | Imposta prima l'URL del webhook. |
| 410 | recall_removed | Richiamo tolto dal feed; replaced_by indica quello che lo sostituisce. |
| 422 | (errors) | Dati non validi: il campo errors elenca i problemi per ogni campo. |
| 422 | watchlist_limit | Lista di sorveglianza piena. |
| 429 | rate_limited | Troppe richieste: riprova dopo i secondi indicati in Retry-After. |
Aggiornamenti e versioni
Controlliamo gli avvisi del Ministero circa ogni 2 ore. La v1 cambia solo aggiungendo: possono comparire nuovi campi, eventi o filtri, quindi ignora i campi che non conosci. Le modifiche incompatibili arriveranno in una v2, con un preavviso. Il servizio è fornito al meglio delle nostre possibilità: se il sito del Ministero cambia o non è raggiungibile i dati possono arrivare in ritardo, e te lo diciamo. Per domande e problemi scrivi a [email protected].