Version bêta : des champs peuvent être ajoutés sans préavis. Aucun champ n'est renommé ni retiré dans /api/v1. Statut et versions

Première synchronisation des avis

Sur cette page

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 :

  1. Copie initiale : vous parcourez GET /avis page après page, avec un curseur, jusqu'à la dernière page.
  2. Vous retenez une date : meta.synchronise_jusqu_a, lue sur la dernière page.
  3. 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.

bash
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) :

bash
curl -s -i -H "X-API-Key: $PK_API_KEY" "$BASE/avis?per_page=100"

Vous obtenez { "data": [...], "meta": {...} }. Dans meta :

ChampSens
per_pageTaille 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.
totalNombre d'avis du parcours, donné sur la première page seulement.
synchronise_jusqu_aLa 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.

bash
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) :

bash
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_a vaut « maintenant moins 10 minutes » : aucun avis ne saute entre deux synchronisations. Ne retranchez rien vous-même.
  • Fenêtre de 30 jours. updated_since ne peut pas remonter à plus de 30 jours. Au-delà, la réponse est 400 resync_required : abandonnez votre copie et refaites la copie initiale, sans updated_since. Une date dans le futur ou mal formée donne 400 invalid_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_retrait dit pourquoi :

    motif_retraitSenscanonique_id
    doublonl'avis est le doublon d'un autrel'avis qui le remplace
    supprimel'avis n'existe plusnull
    source_coupeesa source n'est plus servienull

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) ;
  • version est 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éserve per_page enregistrements avant de répondre : avec per_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 /avis porte X-Quota-Limit et X-Quota-Remaining. GET /moi donne 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_since peut être servi depuis une mémoire de 60 secondes. La reprise updated_since ne l'est jamais.

Erreurs à gérer

Toute erreur a le corps { "error": { "code": "…", "message": "…" } }. Testez error.code, pas le message.

Statuterror.codeQue faire
400invalid_cursorRepartir de la première page, sans curseur, avec les mêmes filtres.
400resync_requiredRefaire une copie initiale complète, sans updated_since.
400invalid_parameterCorriger la requête (paramètre inconnu, répété ou hors bornes). Ne pas réessayer telle quelle.
401unauthorizedClé absente, inconnue ou révoquée.
403forbiddenCompte non Pro, ou clé sans périmètre avis:read.
429rate_limitedAttendre Retry-After secondes, puis réessayer.
429quota_exceededQuota du jour atteint : s'arrêter. Retry-After donne les secondes jusqu'à minuit UTC. Reprendre ensuite depuis la même position.
503unavailableAttendre 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).

js
// 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 :

bash
PK_API_KEY=pk_live_VOTRE_CLE node sync.mjs

Relancez-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é

  1. Avoir un compte Pro

    La clé est réservée aux comptes au plan Pro.

    Créer un compteVoir les offres

  2. Écrire au support

    support@publikconnect.fr

    Nous vérifions votre compte Pro puis vous répondons par e-mail avec votre clé.