Ce guide mène d'une clé neuve à une copie locale des avis de marché, tenue à jour. Il suffit pour un premier appel réussi ; la référence complète est dans la page Synchroniser les avis.
Le principe tient en trois temps :
- Copie initiale : vous parcourez
GET /avispage après page, avec un curseur, jusqu'à la dernière page. - Vous retenez une date :
meta.synchronise_jusqu_a, lue sur la dernière page. - Incrémental : plus tard, vous refaites le même parcours avec
updated_since=<cette date>. Vous ne recevez que les avis nouveaux, modifiés, clos ou retirés depuis. Vous retenez la nouvelle date, et ainsi de suite.
Il n'y a pas de webhook : la reprise par updated_since se rejoue à la fréquence que vous voulez (une fois par heure convient).
Avant de commencer
Il vous faut une clé pk_live_… avec le périmètre avis:read (demandez-la à l'équipe PublikConnect : voir Obtenir une clé). Les exemples utilisent l'adresse de production.
export PK_API_KEY=pk_live_VOTRE_CLE
export BASE=https://publikconnect.fr/api/v1
curl -s "$BASE/sante" # sans clé : l'API répond-elle ?
curl -s -H "X-API-Key: $PK_API_KEY" "$BASE/moi" # votre clé, vos périmètres, votre quota/moi doit lister avis:read dans data.cle.perimetres. Sans ce périmètre, /avis répond 403. /moi ne consomme aucun quota.
Étape 1 : la copie initiale
Premier appel, avec les en-têtes de réponse (-i) :
curl -s -i -H "X-API-Key: $PK_API_KEY" "$BASE/avis?per_page=100"Vous obtenez { "data": [...], "meta": {...} }. Dans meta :
| Champ | Sens |
|---|---|
per_page | Taille de page appliquée (100 au plus ; 50 par défaut en mode curseur). |
curseur_suivant | À repasser dans curseur pour la page suivante. null sur la dernière page. |
total | Nombre d'avis du parcours, donné sur la première page seulement. |
synchronise_jusqu_a | La date à retenir pour l'incrémental (lisez-la sur la dernière page). |
Page suivante : même requête, plus curseur=<meta.curseur_suivant>. Gardez les mêmes filtres d'une page à l'autre (per_page peut changer). Le curseur est opaque : ne le fabriquez pas, ne le décodez pas. Avec d'autres filtres que ceux de la requête qui l'a produit, ou s'il est illisible, la réponse est 400 invalid_cursor : repartez de la première page.
CURSEUR=$(curl -s -H "X-API-Key: $PK_API_KEY" "$BASE/avis?per_page=100" | jq -r '.meta.curseur_suivant')
curl -s -H "X-API-Key: $PK_API_KEY" -G "$BASE/avis" \
--data-urlencode "per_page=100" --data-urlencode "curseur=$CURSEUR"Par défaut la copie ne contient que les avis ouvert. Pour récupérer aussi les avis clos, ajoutez statut=clos ou statut=tous (réservé à la copie initiale : statut est refusé avec updated_since). Les filtres source, departement, cpv, type_marche, budget_min, budget_max, date_limite_min, date_limite_max, publie_depuis et completude_min limitent la copie ; q, tri et page sont incompatibles avec le mode curseur.
Étape 2 : l'incrémental
Avec la date retenue (synchronise_jusqu_a de la dernière page de la fois précédente) :
curl -s -H "X-API-Key: $PK_API_KEY" -G "$BASE/avis" \
--data-urlencode "per_page=100" \
--data-urlencode "updated_since=2026-10-08T09:50:00.000Z"Reprenez la même boucle de curseur. Retenez le synchronise_jusqu_a de la dernière page, même quand elle est vide. Passez-le tel quel dans updated_since.
Trois règles de fond :
- Marge de 10 minutes, déjà comprise. Aucun avis écrit depuis moins de 10 minutes n'est servi, et
synchronise_jusqu_avaut « maintenant moins 10 minutes » : aucun avis ne saute entre deux synchronisations. Ne retranchez rien vous-même. - Fenêtre de 30 jours.
updated_sincene peut pas remonter à plus de 30 jours. Au-delà, la réponse est400resync_required: abandonnez votre copie et refaites la copie initiale, sansupdated_since. Une date dans le futur ou mal formée donne400invalid_parameter. Synchronisez donc au moins une fois par mois, en pratique bien plus souvent. - Tous les statuts sont servis avec
updated_since.
Avis clos et retire
Avec updated_since, un avis déjà copié peut revenir sous deux formes :
-
"statut": "clos": la date limite est passée, ou l'avis a été clos. L'avis est complet. Écrasez votre copie (ou supprimez-la, si vous ne gardez que les avis ouverts). -
"statut": "retire": l'avis n'est plus servi. Il revient sous une forme réduite, sans autre champ :json{ "id": "…", "statut": "retire", "motif_retrait": "doublon", "canonique_id": "…", "version": "…" }Retirez l'avis de votre copie, par
id.motif_retraitdit pourquoi :motif_retraitSens canonique_iddoublonl'avis est le doublon d'un autre l'avis qui le remplace supprimel'avis n'existe plus nullsource_coupeesa source n'est plus servie null
Un avis retiré compte comme un enregistrement servi dans le quota.
Idempotence
Une page peut être rejouée, et la reprise relit une petite marge : le même avis peut vous revenir. Écrivez donc par id, et ne remplacez que si la version est plus récente :
- la clé d'un avis est son
id(UUID) ; versionest un texte à la microseconde (2026-10-08T08:00:00.123456Z) qui avance chaque fois qu'un champ servi change. Comparez-la comme du texte, jamais comme une date : une date JavaScript perd les microsecondes.
Rejouer une synchronisation interrompue est sans danger : relancez la même boucle avec le même updated_since (ou, pour une copie initiale, depuis la première page).
Quota et débit
- Quota journalier : 50 000 enregistrements par jour et par compte pour la famille
general(avis compris), jour UTC. Une page de 100 avis coûte 100. Le service réserveper_pageenregistrements avant de répondre : avecper_page=100, il faut au moins 100 enregistrements de quota pour être servi, même si la page n'en contient que 3. - Lire le quota : chaque réponse de
/avisporteX-Quota-LimitetX-Quota-Remaining.GET /moidonne l'état complet (data.quota.general.restant) sans rien consommer. - Débit : 60 requêtes par minute et par clé. Une boucle séquentielle avec une pause d'une seconde par page reste dessous. Au plus 4 requêtes en cours par clé : ne parallélisez pas la boucle.
- Mémoire de 60 secondes : le parcours par curseur sans
updated_sincepeut être servi depuis une mémoire de 60 secondes. La repriseupdated_sincene l'est jamais.
Erreurs à gérer
Toute erreur a le corps { "error": { "code": "…", "message": "…" } }. Testez error.code, pas le message.
| Statut | error.code | Que faire |
|---|---|---|
| 400 | invalid_cursor | Repartir de la première page, sans curseur, avec les mêmes filtres. |
| 400 | resync_required | Refaire une copie initiale complète, sans updated_since. |
| 400 | invalid_parameter | Corriger la requête (paramètre inconnu, répété ou hors bornes). Ne pas réessayer telle quelle. |
| 401 | unauthorized | Clé absente, inconnue ou révoquée. |
| 403 | forbidden | Compte non Pro, ou clé sans périmètre avis:read. |
| 429 | rate_limited | Attendre Retry-After secondes, puis réessayer. |
| 429 | quota_exceeded | Quota du jour atteint : s'arrêter. Retry-After donne les secondes jusqu'à minuit UTC. Reprendre ensuite depuis la même position. |
| 503 | unavailable | Attendre Retry-After (1 ou 30 secondes), puis réessayer. Rien n'a été compté. |
Support : citer X-Request-Id
Toute réponse, erreurs comprises, porte un en-tête X-Request-Id. Quand vous écrivez au support, joignez-le : il permet de retrouver votre requête dans les journaux. Il est généré par PublikConnect ; un X-Request-Id envoyé par votre client n'est pas repris.
Exemple complet en Node 22
Enregistrez ce fichier sous sync.mjs. Il utilise fetch natif, sans dépendance. Il fait la copie initiale la première fois, puis l'incrémental aux appels suivants, et garde l'état dans sync-state.json (une copie des avis par id, et la date à retenir).
// sync.mjs : node sync.mjs (variables : PK_API_KEY obligatoire, PK_BASE_URL facultative)
import { readFile, writeFile } from "node:fs/promises";
const BASE = process.env.PK_BASE_URL ?? "https://publikconnect.fr/api/v1";
const KEY = process.env.PK_API_KEY;
const STATE_FILE = "sync-state.json"; // { since: "…" | null, avis: { [id]: avis } }
if (!KEY) {
console.error("PK_API_KEY manquante");
process.exit(1);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function loadState() {
try {
return JSON.parse(await readFile(STATE_FILE, "utf8"));
} catch {
return { since: null, avis: {} };
}
}
// GET avec reprise sur 429 `rate_limited` et 503 (on attend Retry-After). Les autres erreurs sont levées.
async function get(path, params) {
const url = new URL(BASE + path);
for (const [name, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") url.searchParams.set(name, String(value));
}
for (let essai = 1; ; essai++) {
const res = await fetch(url, { headers: { "X-API-Key": KEY } });
if (res.ok) return { body: await res.json(), res };
const body = await res.json().catch(() => ({}));
const code = body?.error?.code;
const requestId = res.headers.get("X-Request-Id"); // à citer au support
const retriable = code === "rate_limited" || code === "unavailable";
if (retriable && essai <= 5) {
const secondes = Number(res.headers.get("Retry-After")) || 30;
console.warn(`${res.status} ${code} : nouvel essai dans ${secondes} s (X-Request-Id ${requestId})`);
await sleep(secondes * 1000);
continue;
}
throw Object.assign(new Error(`HTTP ${res.status} ${code ?? "?"} (X-Request-Id ${requestId})`), { code });
}
}
// Écriture idempotente par id : un avis retiré sort de la copie, sinon on garde la version la plus récente.
function appliquer(state, avis) {
if (avis.statut === "retire") {
delete state.avis[avis.id]; // motif_retrait : doublon | supprime | source_coupee
return;
}
const ancien = state.avis[avis.id];
if (ancien && ancien.version >= avis.version) return; // comparaison de TEXTE, jamais de dates
state.avis[avis.id] = avis; // un avis devenu "clos" est écrasé comme les autres
}
// Parcourt toutes les pages ; rend le synchronise_jusqu_a de la dernière page.
async function parcourir(state, updatedSince) {
let curseur;
for (;;) {
const { body, res } = await get("/avis", { per_page: 100, updated_since: updatedSince, curseur });
for (const avis of body.data) appliquer(state, avis);
console.log(`${body.data.length} avis, quota restant ${res.headers.get("X-Quota-Remaining")}`);
if (!body.meta.curseur_suivant) return body.meta.synchronise_jusqu_a;
curseur = body.meta.curseur_suivant;
await sleep(1100); // reste sous 60 requêtes par minute
}
}
const state = await loadState();
try {
state.since = await parcourir(state, state.since); // state.since vaut null à la première fois : copie complète
} catch (err) {
if (err.code !== "resync_required") throw err;
console.warn("Fenêtre de 30 jours dépassée : nouvelle copie complète");
state.avis = {};
state.since = await parcourir(state, null);
}
await writeFile(STATE_FILE, JSON.stringify(state));
console.log(`${Object.keys(state.avis).length} avis en copie ; prochaine reprise depuis ${state.since}`);Lancez-le :
PK_API_KEY=pk_live_VOTRE_CLE node sync.mjsRelancez-le à chaque synchronisation : la première exécution fait la copie initiale, les suivantes ne récupèrent que les changements. L'état n'est écrit qu'à la fin : une exécution interrompue ne perd rien, la suivante repart du même point.
Demander une clé
Avoir un compte Pro
La clé est réservée aux comptes au plan Pro.
Écrire au support
Nous vérifions votre compte Pro puis vous répondons par e-mail avec votre clé.