Aller au contenu

Garder votre système synchronisé

C’est vous qui détenez la fiche client. Le CRM d’un groupe hôtelier, l’entrepôt de reporting d’une agence, le système adhérents d’un programme de fidélité — quelque chose qui sait déjà qui sont ces personnes et doit maintenant savoir ce qu’elles ont réservé. Les réservations du restaurant doivent y arriver et y rester à jour, y compris celles que personne chez vous n’a prises.

Trois mécanismes portent cela, et une synchronisation qui tient un CRM les utilise tous les trois.

D’abord : les webhooks seuls ne sont pas un registre

Section intitulée « D’abord : les webhooks seuls ne sont pas un registre »

Un webhook est le signal qu’il s’est passé quelque chose. Ce n’est pas la garantie que vous avez tout appris, et quatre propriétés de cette distribution le rendent concret :

  • La distribution est au moins une fois, et sans ordre. Vous pouvez recevoir reservation.seated avant reservation.confirmed, et recevoir l’un ou l’autre deux fois.
  • Un point de réception en échec pendant environ trois jours est désactivé, et les événements émis pendant son indisponibilité ne sont pas mis en file pour vous.
  • Les réservations importées depuis un autre prestataire n’émettent rien. Un restaurant en migration continue d’importer, et rien de ce trafic ne déclenche de webhook.
  • Une réservation sur un créneau à garantie est publiée tard, et parfois jamais. Voir Les réservations à garantie arrivent dans le désordre.

Les webhooks vous disent quand regarder. updated_since vous dit ce qui est vrai.

Construisez les deux. Le gestionnaire de webhooks garde votre CRM vivant ; un balayage updated_since périodique le remet juste après une panne, une migration ou une distribution manquée. Une intégration réduite au premier est juste la plupart du temps et discrètement fausse le jour qui compte.

Portée Ce qu’elle sert ici
reservations:read Les réservations, leurs événements, les options et la file d’attente. Toute la surface de réservation.
guests:read Les fiches client : GET /v1/guests, ?expand=guest et ?expand=modification_token sur POST /v1/reservations. Aussi les coordonnées et les notes client de chaque réservation, et les notes de chaque entrée de file d’attente.

Une réservation porte sa propre taille de groupe, ses horaires et son statut ; la personne est un identifiant gst_… et rien de plus tant que vous ne l’étendez pas. Les coordonnées propres à la réservation, les notes du client et la note du restaurant (contact_email, contact_phone, guest_notes, internal_notes) sont aussi des données client, absentes pour une clé sans guests:read : voir Données client d’une réservation. Une clé limitée à reservations:read synchronise un flux de réservations complet et aucune coordonnée.

C’est la bonne clé pour un entrepôt de reporting et la mauvaise pour un CRM. Décidez ce que vous construisez, et demandez au restaurant la portée la plus étroite quand elle suffit : les noms, adresses et numéros des clients sont les relations du restaurant, et une portée que vous ne détenez pas est une portée que vous ne pouvez pas laisser fuir. expand=guest est contrôlé en un point de passage unique : une clé sans guests:read reçoit un 403 insufficient_scope avec param: "expand" d’où qu’elle atteigne le client, y compris depuis une entrée de file d’attente. Voir Portées.

Enregistrer le point de réception, et vérifier ce qui arrive

Section intitulée « Enregistrer le point de réception, et vérifier ce qui arrive »

Les points de réception sont en libre-service : le restaurant ajoute le vôtre dans Paramètres → Développeurs, choisit les événements et révèle un secret de signature. Jusqu’à cinq points par restaurant, chacun épinglé à la version de l’API en vigueur à sa création.

Deux pages couvrent le reste, et ce sont elles la référence :

  • Webhooks — l’enveloppe, les réessais, la désactivation automatique, et pourquoi vous dédupliquez sur event.id.
  • Vérifier les signatures — à faire avant d’analyser le corps. L’URL de votre point de réception est publique ; la signature est la seule chose qui atteste que la livraison vient de Service.

Le catalogue d’événements liste les 30 types d’événements avec une charge utile complète pour chacun.

Les portées de votre clé n’atteignent pas le point de terminaison. Une livraison porte les coordonnées et les notes de la réservation, et les événements guest.* portent la fiche client, même quand la clé avec laquelle vous synchronisez ne porte que reservations:read. Voir Les livraisons portent les données client.

Savoir quels événements se déclenchent, et lesquels non

Section intitulée « Savoir quels événements se déclenchent, et lesquels non »

Le catalogue répond événement par événement. Quatre schémas causent la plupart des bugs de synchronisation, et il vaut la peine de les avoir en tête en écrivant le gestionnaire.

Un statut atteint automatiquement ne déclenche rien de plus. reservation.confirmed se déclenche quand une réservation en attente est approuvée plus tard. Une réservation confirmée d’office à la création ne le déclenche jamais : son reservation.created portait déjà status: "confirmed". Un gestionnaire qui attend confirmed avant d’écrire la réservation dans votre CRM attendra indéfiniment chez la plupart des restaurants.

Certaines transitions déclenchent deux événements. Une option convertie émet hold.converted et reservation.created, à propos du même groupe. Une promotion depuis la file d’attente émet waitlist_entry.promoted et un événement de réservation. Comptez un événement par paire, sinon vous comptez les couverts deux fois.

Un changement du seul champ d’exonération émet désormais reservation.updated. Poser ou retirer excluded_from_shift_limits ne changeait rien de visible auparavant ; l’événement part maintenant, avec previous_attributes portant l’ancienne valeur. C’est le signal qu’une réservation a quitté ou rejoint le pool de couverts du service, ce qui compte pour tout ce qui, chez vous, rapporte sur la capacité. Voir Réserver malgré les règles.

previous_attributes ne voyage que sur les événements *.updated, et parmi ceux-là seuls reservation.updated et waitlist_entry.updated en construisent un — guest.updated n’en porte aucun. Sur tous les autres types, data.object est la ressource complète et le différentiel est à vous de le calculer contre votre propre copie. La règle propre au champ est sur L’objet événement de webhook.

Les réservations à garantie arrivent dans le désordre

Section intitulée « Les réservations à garantie arrivent dans le désordre »

Cette section mérite d’exister, parce qu’une synchronisation fondée sur les seuls webhooks se trompe dans les deux sens.

Une création sur un créneau portant une garantie par carte répond 201 avec la réservation en awaiting_guarantee, et le client reçoit un lien de paiement par e-mail. La réservation est lisible par votre clé dès cet instant : GET /v1/reservations/{id} la résout et ?status=awaiting_guarantee la retourne. Mais son webhook reservation.created est retenu jusqu’à l’enregistrement effectif de la carte.

Deux conséquences :

  • Le webhook arrive après la réservation. Si le client enregistre sa carte deux heures plus tard, c’est à ce moment-là que reservation.created part, bien après votre 201, avec status à confirmed, ou à pending quand le service demande une validation. Un PATCH qui supprime le besoin de carte le fait partir aussi. Aucun autre événement reservation.* sur la réservation ne le précède, pas même le reservation.updated qu’un PATCH entre-temps aurait envoyé.
  • Si le client n’enregistre jamais de carte, rien ne part. La demande expire, la réservation est annulée, et il n’y a pas non plus de reservation.cancelled : un webhook d’annulation sans created avant lui décrirait une réservation dont votre système n’a jamais entendu parler.

Une synchronisation qui se recale sur l’API plutôt que sur le flux d’événements voit la réservation, la voit se régler et la voit disparaître. Une synchronisation qui se fie au flux n’en voit rien. Demander une garantie par carte couvre la suite de ce parcours.

Fenêtre de terminal
curl -G "https://api.useservice.app/v1/reservations" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
--data-urlencode "updated_since=2026-09-21T06:00:00Z" \
--data-urlencode "limit=100"

Le flux de changements est disponible sur GET /v1/reservations, GET /v1/guests et GET /v1/waitlist-entries. Le passer bascule le tri en (updated_at, id) croissant : vous lisez le changement le plus ancien d’abord et paginez avec starting_after comme ailleurs. Pagination en donne la mécanique, y compris pourquoi le flux est « au moins une fois » et pourquoi votre fenêtre doit chevaucher la précédente au lieu de reprendre exactement où vous vous êtes arrêté.

Trois choses que ce flux ne fait pas :

  • Les options n’ont pas d’updated_since. GET /v1/holds filtre sur status et date à la place. Une option est brève par construction : les quatre événements hold.* sont sa surface de synchronisation, et une option convertie laisse une réservation que le flux porte, lui.
  • Les statistiques client sont calculées à la lecture. visit_count, no_show_count et cancellation_count ne font pas partie de la surface synchronisée et ne déplacent pas à eux seuls l’updated_at d’un client. Lisez la fiche quand vous en avez besoin.
  • Il ne dit pas ce qui a changé, seulement que quelque chose a changé. Comparez à votre propre copie, ou lisez les événements de la réservation pour un historique champ par champ.

Choisissez une cadence selon le besoin du restaurant, pas selon ce que la limite autorise. Une fois par minute est généreux pour un balayage de recalage placé à côté d’un gestionnaire de webhooks vivant ; une fois par heure suffit à un entrepôt.

updated_since répond à ce qui a changé. Pour lire ce qui est réservé ce soir, filtrez plutôt sur la date de service :

Fenêtre de terminal
curl -G "https://api.useservice.app/v1/reservations" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
--data-urlencode "date=2026-09-21" \
--data-urlencode "limit=100"

date[gte] et date[lte] prennent un intervalle, bornes incluses, et GET /v1/holds accepte les trois mêmes. Sans updated_since, la liste est triée par date puis par identifiant, la plus récente en premier. Le filtre s’appelle date bien que le champ de chaque réservation soit service_date : ?service_date= est refusé par un 400 qui le nomme, si bien qu’une faute de frappe ne renvoie jamais toutes les dates sous l’apparence d’une réponse filtrée.

Les lectures et les écritures tirent sur deux budgets distincts, choisis par méthode HTTP : GET, HEAD et OPTIONS dépensent le budget de lecture, toute autre méthode celui d’écriture. Une synchronisation est presque entièrement faite de GET : elle dépense du quota de lecture et laisse intact le budget d’écriture, de loin le plus petit des deux, pour le reste de votre intégration.

Cela compte plus qu’il n’y paraît. Une boucle d’interrogation qui reste dans le budget de lecture ne peut pas affamer une création, et une rafale de créations ne peut pas bloquer la synchronisation. Lorsqu’une réponse porte RateLimit-Resource, lisez-le pour savoir lequel des deux budgets les autres en-têtes décrivent ; les chiffres, le comportement en rafale et la forme du 429 sont dans Limites de débit.

Les GET de ressource unique portent un ETag. Renvoyez-le en If-None-Match et une ressource inchangée répond 304 Not Modified sans corps :

Fenêtre de terminal
curl -i "https://api.useservice.app/v1/reservations/resv_8xKQ2m4Vd0pErJ7sN1aZ9bQ?expand=guest" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
-H 'If-None-Match: W/"resv_8xKQ2m4Vd0pErJ7sN1aZ9bQ-1719515575000000-guest-guest_data"'

L’ETag varie selon l’ensemble expand : si vous mettez en cache par identifiant de ressource, il faut désormais mettre en cache par (identifiant, expand). La raison est un bug qu’il vaut mieux comprendre que contourner. Un ETag ignorant expand permettait à un client détenant le corps simple de revalider en demandant ?expand=guest et de recevoir un 304. Or un 304 signifie « le corps que vous détenez est à jour » : le client en concluait que l’objet client n’existe pas — une mauvaise réponse qui ressemble exactement à une bonne.

L’ensemble est trié avant d’entrer dans la clé : expand=guest,events et expand=events,guest partagent un validateur.

L’ETag varie aussi selon les données client. Un corps qui porte les coordonnées et les notes de la réservation se termine par -guest_data : un client qui passe à une clé portant guests:read reçoit un 200 avec le corps complet, jamais un 304 pour la copie qu’il détient sans elles. Une requête sans expand, pour un corps sans données client, produit l’ETag que cette API a toujours produit.

Étendre les objets décrit ce que chaque ressource peut étendre et la taille de page réduite qui accompagne une extension sur une liste.

Code Quand Que faire
insufficient_scope, param: "expand" 403 sur toute lecture atteignant un client La clé n’a pas guests:read. Retirez le paramètre et synchronisez les réservations sans coordonnées, ou demandez au restaurant une clé qui la porte.
insufficient_scope, sans param 403 La clé ne peut pas appeler ce point de terminaison. Renvoyer ne change rien.
bad_request, param: "updated_since" 400 L’horodatage n’est pas au format ISO-8601.
bad_request, param: "service_date" 400 Le filtre de liste est date, ou date[gte] et date[lte].
bad_request, param: "expand" 400 Une relation inconnue. Une faute de frappe ne livre jamais en silence des données non étendues.
rate_limited 429 Respectez Retry-After, puis reculez avec un peu de gigue. Regardez RateLimit-Resource pour savoir quel budget est épuisé.
not_found 404 Une ressource appartenant à un autre restaurant répond 404, pas 403. Une clé, un restaurant.
plan_required 403 L’accès à l’API est une option Premium. La clé est valide ; l’abonnement du restaurant ne l’est pas.

Ce qu’une synchronisation de production fait de plus

Section intitulée « Ce qu’une synchronisation de production fait de plus »

Une démonstration affiche le corps du webhook. Cinq choses la séparent d’une synchronisation qu’un groupe de restaurants peut exploiter.

Elle vérifie chaque signature, y compris celles qu’elle s’apprête à ignorer. L’URL est publique. Un corps non signé est l’opinion d’un inconnu sur votre CRM.

Elle est idempotente des deux côtés. Dédupliquez les webhooks sur event.id, insérez-ou-mettez-à-jour le flux updated_since par identifiant de ressource, et les deux mécanismes cessent de se disputer la même réservation. Vous pouvez alors lancer le balayage aussi souvent que vous voulez.

Elle stocke le repère qu’elle a réellement terminé. Pas « maintenant », et pas le dernier updated_at lu : la dernière page validée. Reprenez un peu avant ce repère, et acceptez les doublons.

Elle se recale à intervalle régulier, pas seulement après un incident. Un balayage horaire retrouve une réservation importée, une demande de garantie expirée en silence et une livraison perdue, sans que personne ait remarqué leur absence. Un balayage lancé quand quelqu’un se plaint les retrouve une semaine trop tard.

Elle détient les portées qu’elle utilise, et pas davantage. Un entrepôt qui rapporte sur les couverts n’a pas besoin de guests:read. La demander quand même fait du fichier client du restaurant votre problème à protéger, sans aucune fonctionnalité en échange.