Synchroniser un autre système

Ce guide montre comment maintenir un système externe synchronisé avec Initiative : effectuer un premier chargement complet, une seule fois, puis une boucle répétable de type « qu'est-ce qui a changé depuis mon dernier passage » qui capture aussi les suppressions, exécutée selon une planification, sans jamais perdre ni dupliquer une fiche. Il vous faut seulement un jeton d'API et votre URL de base, comme indiqué dans le guide de démarrage. Chaque appel ci-dessous utilise les mêmes mécanismes de filtre/tri/pagination — consultez Interrogation : filtres, tri et pagination pour le tableau complet des opérateurs et les règles de pagination ; ce guide ne couvre que le schéma spécifique à la synchronisation construit par-dessus.

1. La forme d'une synchronisation

Une boucle de synchronisation, c'est trois choses, exécutées dans cet ordre :

  1. Un premier chargement complet, une seule fois — parcourez toutes les pages d'un module pour amorcer votre copie à partir de rien.
  2. Un flux de changements, selon une planification — ne demandez que ce qui a changé depuis votre dernière exécution réussie.
  3. Un flux de suppressions, selon la même planification — une fiche qui cesse simplement d'apparaître dans le flux de changements est indiscernable d'une fiche « non modifiée » ; les suppressions ont besoin de leur propre endpoint.

Entre deux exécutions, vous stockez exactement une chose par module : un repère de progression (high-water mark) — un horodatage marquant jusqu'où vous avez traité avec succès. L'étape 2 l'avance correctement ; l'étape 3 explique pourquoi « maintenant » est la mauvaise valeur vers laquelle l'avancer. Parce que le flux de changements et le flux de suppressions progressent chacun de façon indépendante au cours d'une même exécution, le seul repère que vous conservez est le plus ancien des deux progrès, pas simplement l'un des deux — la boucle complète de l'étape 7 montre exactement comment faire.

2. Premier chargement

Parcourez chaque page une fois, triée par id croissant, pour amorcer votre copie :

curl -sS -G -H "Authorization: Bearer itv_YOUR_TOKEN_HERE" "https://app.initiative-crm.com/api/v1/contacts" \
  --data-urlencode 'per_page=100' --data-urlencode 'sort=id' --data-urlencode 'order=asc' \
  --data-urlencode 'page=1' | jq '.meta'
{
  "total": 226,
  "page": 1,
  "per_page": 100,
  "pages": 3,
  "request_id": "324f8b74-d12c-4ac4-8b64-99300cf90e4e"
}

Répétez avec page=2, page=3, … jusqu'à meta.pages.

Pourquoi sort=id&order=asc et pas le comportement par défaut. Omettre sort/order renvoie un tri décroissant sur modifiedtime — comme le prévient déjà Interrogation : filtres, tri et pagination, si une fiche est modifiée pendant que vous êtes en plein parcours, son modifiedtime change et la fiche peut basculer dans une page que vous avez déjà récupérée, ou sortir d'une page que vous n'avez pas encore atteinte, ce qui peut silencieusement la sauter ou la dupliquer au fil du parcours. id ne change jamais une fois qu'une fiche existe, donc un parcours de pages trié par id reste stable quoi qu'il arrive d'autre pendant que vous le parcourez.

3. Seulement ce qui a changé

Une fois que vous disposez d'une copie complète, chaque exécution ultérieure n'a besoin que des fiches modifiées depuis votre repère de progression stocké — triées par ordre croissant cette fois, de sorte que la dernière fiche des résultats soit la plus récente que vous ayez vue :

SINCE="2026-01-01T00:00:00Z"
curl -sS -G -H "Authorization: Bearer itv_YOUR_TOKEN_HERE" "https://app.initiative-crm.com/api/v1/contacts" \
  --data-urlencode "filter[modifiedtime][gte]=$SINCE" \
  --data-urlencode 'sort=modifiedtime' --data-urlencode 'order=asc' \
  --data-urlencode 'per_page=100' | jq '.meta.total, [.data[].attributes.modifiedtime][0:3]'
225
[
  "2026-08-03T11:22:26Z",
  "2026-08-03T11:22:26Z",
  "2026-08-03T11:22:27Z"
]

Parcourez les pages de la même façon qu'à l'étape 2 (per_page=100, en avançant page) jusqu'à avoir traité tous les résultats.

Avancez le repère de progression jusqu'à la dernière fiche réellement traitée — jamais jusqu'à « maintenant ». Il est tentant de simplement enregistrer l'heure de fin de votre exécution, mais cela laisse un trou : votre exécution a commencé en demandant tout ce qui s'est passé depuis le repère précédent ; pendant qu'elle était encore en train de récupérer et de traiter des pages, une autre écriture a pu arriver avec un modifiedtime postérieur à votre ancien repère mais antérieur au démarrage de votre exécution. Que cette écriture se retrouve ou non dans les résultats de cette exécution dépend uniquement du fait qu'elle ait été validée avant ou après l'exécution de votre requête — un timing que vous ne contrôlez pas. Si vous fixez ensuite le prochain repère à « maintenant » (l'heure à laquelle votre exécution s'est terminée, pas l'heure à laquelle chaque fiche a réellement été vue), vous excluez définitivement cette fiche : le filtre de la prochaine exécution est modifiedtime >= maintenant, et l'horodatage de la fiche lui est antérieur. Elle tombe dans le trou entre deux exécutions et n'est jamais récupérée. Fixer le repère au modifiedtime de la dernière fiche réellement traitée garde le filtre prudent — tout ce que vous auriez pu manquer cette fois a encore un modifiedtime égal ou postérieur à ce repère, donc c'est encore capturé au passage suivant. Comme le filtre est inclusif (gte), la fiche qui vous a servi à fixer le repère réapparaîtra aussi au début de la prochaine exécution — c'est attendu et sans danger tant que vous appliquez les changements comme un upsert indexé sur id, et non comme un simple ajout.

4. Suppressions

Une fiche supprimée renvoie 404 sur un GET direct et disparaît de tous les résultats de liste et de filtre, si bien que rien ne distingue « supprimée » de « n'a jamais existé » en dehors du flux de suppressions. La seule façon d'apprendre qu'une fiche a disparu est le flux de suppressions dédié, qui exige since :

curl -sS -G -H "Authorization: Bearer itv_YOUR_TOKEN_HERE" "https://app.initiative-crm.com/api/v1/contacts/deleted" \
  --data-urlencode 'since=2026-01-01T00:00:00Z' | jq '.data[0], .meta'
{
  "id": "1592",
  "type": "contacts",
  "deleted_at": "2026-08-03T14:38:58Z"
}
{
  "total": 4,
  "page": 1,
  "per_page": 20,
  "pages": 1,
  "since": "2026-01-01T00:00:00Z",
  "request_id": "b70a5ffc-90cb-4313-9da6-2ad70412aa41"
}

Chaque fiche de suppression (tombstone) comporte exactement trois champs : id, type et deleted_at — rien d'autre ne survit à une suppression, ce n'est donc pas un endroit où chercher les derniers attributs connus d'une fiche. Sur les 4 fiches de suppression renvoyées ici, deleted_at revenait avec la suppression la plus récente en premier (ordre décroissant) à chaque requête — cet endpoint ne prend pas de paramètre sort/order propre, considérez donc cet ordre comme fixe plutôt que comme quelque chose que vous pouvez changer. Omettez since et la requête est rejetée purement et simplement, 400, avant même qu'une interrogation ait lieu :

{
  "error": {
    "code": "INVALID_FILTER",
    "message": "Query parameter 'since' is required.",
    "request_id": "a9514af4-a24e-4a09-bede-ced5bc3d809f"
  }
}

Interrogez cet endpoint avec le même repère de progression que celui que vous avancez pour le flux de changements, et appliquez la même règle « avancez jusqu'à la dernière fiche de suppression traitée, pas jusqu'à maintenant » que celle de l'étape 3.

5. Interrogation ou webhooks

Ce guide porte entièrement sur l'interrogation (polling) : vous décidez quand interroger, et vous avez toujours au plus un intervalle d'interrogation de retard. Initiative propose aussi des webhooks sortants — voir la documentation Webhooks — qui poussent une notification vers une URL que vous hébergez au moment où une fiche est créée, modifiée ou supprimée, avec vérification de signature, une planification de nouvelles tentatives s'étalant sur plusieurs jours pour les livraisons échouées, et un endpoint de relivraison pour rejouer une livraison précise. Les webhooks vous offrent une latence bien plus faible et ne vous demandent pas de gérer le moindre repère de progression, mais ils dépendent de la joignabilité de votre endpoint, et un abonnement qui échoue suffisamment de fois de suite est automatiquement désactivé jusqu'à ce que vous le réactiviez — une livraison perdue à cause d'une interruption ou d'un mauvais déploiement de votre côté est perdue, sauf si vous repérez la désactivation automatique et allez chercher ce que vous avez manqué. (Consultez la référence intégrateur des Webhooks sortants pour le contrat complet — vérification de signature, planification des nouvelles tentatives, règle de désactivation automatique, et endpoint de relivraison.)

Les deux sont recommandés. Utilisez les webhooks pour réagir rapidement, et gardez une interrogation périodique — la boucle de ce guide — comme filet de rattrapage : quelque chose qui capture tout ce qu'une livraison de webhook manquée, retardée ou auto-désactivée ne vous a pas transmis, selon la planification avec laquelle vous êtes à l'aise de prendre du retard.

6. Respecter la limite de débit

Chaque réponse porte trois en-têtes, que vous soyez proche ou non de la limite :

En-têteTypeValeur d'exemple
X-RateLimit-Limitentier1000000
X-RateLimit-Remainingentierdécrémenté de 1 par requête (999999999998 → …), puis réinitialisé
X-RateLimit-Resettimestamp Unixavance d'environ une seconde à la fois
curl -sS -D - -o /dev/null -H "Authorization: Bearer itv_YOUR_TOKEN_HERE" "https://app.initiative-crm.com/api/v1/contacts?per_page=1"
X-RateLimit-Limit: 1000000
X-RateLimit-Remaining: 999999
X-RateLimit-Reset: 1785844741

Remaining chute d'exactement 1 à chaque requête, et Reset avance vers la fenêtre d'une seconde suivante, moment où Remaining revient à la limite complète — c'est un seau de type « N requêtes par seconde » qui se remplit chaque seconde, exactement comme documenté dans la section Limitation du débit de la référence OpenAPI.

Lorsque vous épuisez le seau, l'API répond 429 avec un en-tête Retry-After. Attendez exactement ce nombre de secondes avant votre prochaine requête.

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1748340000
Retry-After: 42
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded.",
    "request_id": "1f3c8e2a-..."
  }
}

Attendez exactement le nombre de secondes indiqué dans Retry-After avant votre prochaine requête. Réessayer immédiatement n'aide pas — le seau est toujours vide, et vous ne ferez que déclencher un autre 429.

7. Une boucle complète

Une ébauche commentée qui relie entre eux le premier chargement, le flux de changements, les suppressions et la gestion de la limite de débit. Elle stocke le repère de progression dans un fichier local pour qu'une exécution planifiée puisse reprendre là où la précédente s'est arrêtée :

#!/usr/bin/env bash
set -euo pipefail

BASE="https://app.initiative-crm.com"
TOKEN="itv_YOUR_TOKEN_HERE"
MODULE="contacts"
MARK_FILE=".sync-mark-${MODULE}"

# Capturé maintenant, avant que quoi que ce soit d'autre ne s'exécute — c'est l'amorce
# utilisée si aujourd'hui est la toute première synchronisation de ce module (voir
# l'étape 1). Elle doit être prise AVANT la toute première requête du premier
# chargement, pas après sa fin : un chargement complet d'un vrai jeu de données peut
# prendre des minutes (davantage encore une fois que la limite de débit de l'étape 6
# commence à le freiner), et une fiche modifiée sur une page déjà lue a besoin d'un
# modifiedtime encore >= au repère amorcé, sinon la toute première exécution du flux
# de changements ci-dessous la manquera — exactement l'erreur du « maintenant » que
# l'étape 3 dénonce, simplement déplacée de l'avance vers l'amorce.
PRE_LOAD_MARK=$(date -u +%Y-%m-%dT%H:%M:%SZ)

# Un wrapper GET qui expose les en-têtes de limite de débit et temporise sur un 429
# au lieu de réessayer immédiatement.
api_get() {
  local response
  response=$(curl -sS -G -D /tmp/headers.$$ -H "Authorization: Bearer $TOKEN" "$@")
  local status
  status=$(grep -i '^HTTP/' /tmp/headers.$$ | tail -1 | awk '{print $2}')
  if [ "$status" = "429" ]; then
    local retry_after
    retry_after=$(grep -i '^Retry-After:' /tmp/headers.$$ | awk '{print $2}' | tr -d '\r')
    echo "Rate limited — waiting ${retry_after}s before retrying." >&2
    sleep "${retry_after:-5}"
    api_get "$@"   # ne jamais réessayer sans avoir attendu d'abord
    return
  fi
  rm -f /tmp/headers.$$
  echo "$response"
}

# --- Étape 1 : premier chargement, seulement si ce module n'a jamais été synchronisé ---
if [ ! -f "$MARK_FILE" ]; then
  page=1
  while :; do
    resp=$(api_get "$BASE/api/v1/$MODULE" \
      --data-urlencode 'per_page=100' --data-urlencode 'sort=id' --data-urlencode 'order=asc' \
      --data-urlencode "page=$page")
    echo "$resp" | jq -c '.data[]'   # ... appliquer/upserter chaque fiche localement ...
    pages=$(echo "$resp" | jq '.meta.pages')
    [ "$page" -ge "$pages" ] && break
    page=$((page + 1))
  done
  # Amorcer le repère à l'horodatage capturé AVANT le début du chargement (voir
  # PRE_LOAD_MARK ci-dessus) — jamais à la fin du chargement. C'est ce qui rend
  # sûre la récupération, dès la toute prochaine exécution, de tout ce qui a été
  # modifié en cours de chargement.
  echo "$PRE_LOAD_MARK" > "$MARK_FILE"
fi

MARK=$(cat "$MARK_FILE")
LAST_SEEN="$MARK"
LAST_DELETED_SEEN="$MARK"

# --- Étape 2 : flux de changements depuis le repère de progression stocké ---
page=1
while :; do
  resp=$(api_get "$BASE/api/v1/$MODULE" \
    --data-urlencode "filter[modifiedtime][gte]=$MARK" \
    --data-urlencode 'sort=modifiedtime' --data-urlencode 'order=asc' \
    --data-urlencode 'per_page=100' --data-urlencode "page=$page")
  echo "$resp" | jq -c '.data[]'   # ... appliquer/upserter chaque fiche localement ...
  last_in_page=$(echo "$resp" | jq -r '.data[-1].attributes.modifiedtime // empty')
  [ -n "$last_in_page" ] && LAST_SEEN="$last_in_page"
  pages=$(echo "$resp" | jq '.meta.pages')
  [ "$page" -ge "$pages" ] && break
  page=$((page + 1))
done

# --- Étape 3 : suppressions depuis le même repère ---
page=1
while :; do
  resp=$(api_get "$BASE/api/v1/$MODULE/deleted" --data-urlencode "since=$MARK" --data-urlencode "page=$page")
  echo "$resp" | jq -c '.data[]'   # ... supprimer localement l'id de chaque fiche de suppression ...
  # Tombstones come back newest-deleted-first, so the single newest deleted_at
  # across the *whole* walk is always on page 1; later pages are older and
  # must not overwrite it.
  if [ "$page" -eq 1 ]; then
    newest_deleted=$(echo "$resp" | jq -r '.data[0].deleted_at // empty')
    [ -n "$newest_deleted" ] && LAST_DELETED_SEEN="$newest_deleted"
  fi
  pages=$(echo "$resp" | jq '.meta.pages')
  [ "$page" -ge "$pages" ] && break
  page=$((page + 1))
done

# --- Avancer le repère jusqu'à ce qui a réellement été traité — jamais jusqu'à « maintenant » ---
# Le flux de changements et le flux de suppressions ont chacun progressé de leur côté
# pendant cette exécution (LAST_SEEN, LAST_DELETED_SEEN) ; on conserve le PLUS ANCIEN des
# deux, pas simplement l'un des deux. Sinon, un flux qui n'aurait rien eu de nouveau
# cette fois (et dont la propre progression ne se serait donc jamais éloignée de
# l'ancien repère) verrait son absence de progression silencieusement écrasée par
# l'avance de l'autre flux — et une suppression ou une mise à jour de fiche tombant
# dans le trou entre « la requête du flux le plus lent s'est exécutée » et « le repère
# est écrit » serait manquée pour toujours, la même famille de bug que celle déjà
# dénoncée à l'étape 3, simplement répartie sur deux flux au lieu d'un seul.
SAFE_MARK=$(printf '%s\n%s\n' "$LAST_SEEN" "$LAST_DELETED_SEEN" | sort | head -1)
echo "$SAFE_MARK" > "$MARK_FILE"

Erreurs courantes

  • Parcourir une synchronisation avec le tri par défaut. Le tri par défaut est modifiedtime décroissant ; une fiche modifiée en plein parcours change de page et peut être sautée ou dupliquée. Utilisez sort=id&order=asc pour un premier chargement (étape 2).
  • Fixer le repère de progression à now() au lieu de la dernière fiche traitée. Une écriture qui arrive pendant que votre exécution est encore en cours peut tomber dans le trou et être manquée pour toujours (étape 3).
  • Oublier since sur l'endpoint des suppressions. GET /{module}/deleted sans since est rejeté purement et simplement : 400 INVALID_FILTER, "Query parameter 'since' is required."
  • Interroger les suppressions avec une requête de liste ordinaire. Une fiche supprimée renvoie 404 et disparaît de tous les résultats de liste/filtre — il n'existe aucun indicateur « supprimé » sur lequel filtrer via GET /{module}. Vous devez interroger explicitement GET /{module}/deleted (étape 4).
  • Réessayer un 429 sans attendre. Le seau est toujours vide ; attendez exactement le nombre de secondes indiqué par Retry-After avant votre prochaine requête, sinon vous déclencherez un autre 429.
  • Supposer qu'un per_page supérieur à 100 vous donne plus de résultats par requête. Il est silencieusement plafonné à 100 — voir Interrogation : filtres, tri et pagination — une boucle de synchronisation sur un grand jeu de données a donc toujours besoin de la boucle de pagination des étapes 2/7, pas d'une seule requête surdimensionnée.

Did this page help you?