Aller au contenu

Construire un tunnel de réservation

Un tunnel de réservation est la séquence que parcourt un client : choisir une date, choisir une heure, dire qui il est, obtenir une confirmation. Quatre points de terminaison la portent — GET /v1/configuration, GET /v1/availability/months, GET /v1/availability et POST /v1/reservations — et tout le reste de cette page traite des réponses que ces quatre-là vous donnent quand la réservation ne passe pas du premier coup.

Toutes les informations d’authentification émises par cette API sont secrètes et api.useservice.app n’accorde aucun accès cross-origin à une origine que vous contrôlez. Service ouvre CORS pour /v1/public/* et pour rien d’autre, et ce sont là les points de terminaison non authentifiés du widget de réservation, sur l’hôte de l’application, et non une partie de cette API. Une clé sk_live_… ne peut donc pas fonctionner depuis une page livrée aux clients : ni derrière une étape de build, ni dans un service worker, ni avec un en-tête de proxy. Voir Aucune clé utilisable dans un navigateur.

Reconstruire le tunnel, c’est reconstruire le tunnel et faire tourner un serveur.

Le navigateur du client parle à votre backend ; votre backend détient la clé et appelle Service. Si vous avez déjà un backend, c’est un petit ajout. Sinon, c’est une nouvelle brique d’infrastructure à construire et à exploiter.

Si vous voulez le parcours de réservation sur votre site sans aucun backend, intégrez plutôt le widget de réservation. Il ne demande aucune clé et c’est le même tunnel.

Une clé d’API appartient à un seul restaurant. Il n’existe pas de clé de groupe ni de paramètre restaurant_id : le restaurant est résolu à partir de la clé, ce qui fait de GET /v1/configuration le seul moyen de confirmer à quel restaurant vous parlez.

Un groupe de dix établissements, ce sont dix clés, créées dix fois dans dix back-offices, et chacune de vos requêtes porte sur exactement l’un d’eux. Votre serveur choisit la clé avant de choisir le point de terminaison. Construisez dès le premier jour la correspondance entre votre propre identifiant de site et une clé : la rétro-adapter à l’arrivée d’un deuxième restaurant oblige à reprendre chaque appel.

Portée Pourquoi le tunnel en a besoin
reservations:read La configuration, les sections et les deux points de terminaison de disponibilité.
reservations:write La création.
guests:read Uniquement pour ?expand=modification_token sur POST /v1/reservations, voir Terminer le travail. Les coordonnées et les notes d’une réservation l’exigent aussi, y compris sur celles que le tunnel a prises lui-même : voir Données client d’une réservation.
reservations:write:override Uniquement si le tunnel a le droit de réserver au-delà d’une règle posée par le restaurant. Un tunnel ouvert au public ne l’a en général pas.

Erreurs fait référence sur ce que chaque portée accorde et sur la forme d’un refus. L’accès à l’API relève de l’offre Premium : une clé sur un restaurant qui ne l’a pas est valide et refusée quand même, avec plan_required.

Fenêtre de terminal
curl "https://api.useservice.app/v1/configuration" \
-H "Authorization: Bearer $SERVICE_API_KEY"

C’est l’amorçage. Récupérez-le au démarrage, gardez-le et revalidez-le avec If-None-Match plutôt que de le relire à chaque client. Il change quand un gestionnaire change un réglage, pas à chaque requête.

Quatre champs décident du comportement de tout le reste du tunnel :

  • timezone — chaque service_date et chaque HH:MM de cette API est une heure murale dans le fuseau du restaurant. Jamais UTC, jamais celui du navigateur. Convertissez à la frontière de votre système et gardez la journée du restaurant à l’intérieur.
  • min_party_size / max_party_size / max_advance_days — la politique de réservation en ligne du restaurant. En dehors, la création est refusée avec party_size_too_small, party_size_too_large ou advance_window_exceeded. À l’intérieur, un nombre de couverts peut n’avoir aucune table : la politique peut dire 1 à 20 dans un restaurant dont les tables accueillent 2 à 8 personnes. Construisez le champ du nombre de couverts à partir de party_sizes sur GET /v1/availability/months, ci-dessous, qui liste les nombres de couverts plaçables. last_service_date vaut current_date + max_advance_days, pré-calculé pour borner un calendrier par une comparaison de chaînes.
  • guest_languages — les langues dans lesquelles ce restaurant entretient ses textes destinés aux clients. Ce tableau borne ?locale= ci-dessous et il borne la langue dans laquelle un client peut être écrit.
  • default_cutoff_minutes — la valeur par défaut de la maison pour la fermeture des réservations en ligne, et non la valeur appliquée à un service donné. Celle-ci est propre à chaque service et arrive avec les disponibilités.

GET /v1/availability/months?month=2026-10 renvoie les jours qui ont de la disponibilité, pour tous les nombres de couverts à la fois. Il ne prend ni nombre de couverts ni section : une seule réponse alimente le sélecteur de mois entier et reste valable pendant que le client change d’avis sur le nombre de convives. Chaque jour porte les nombres de couverts qui passent, ou la raison unique pour laquelle il n’est pas proposé. Les raisons, dans l’ordre où Service les vérifie, sont sur L’objet disponibilité.

GET /v1/availability?service_date=2026-10-04&party_size=4 répond les heures, pour une date et un nombre de couverts, service par service.

Fenêtre de terminal
curl -G "https://api.useservice.app/v1/availability" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
--data-urlencode "service_date=2026-10-04" \
--data-urlencode "party_size=4" \
--data-urlencode "locale=fr"

Les deux montrent ce qui était vendable au moment où vous les avez lues. Ni l’une ni l’autre ne réserve quoi que ce soit. La création revalide chaque plafond et chaque table sous verrou, ce qui explique qu’un tunnel ait autant besoin du chemin de refus plus bas que de celui-ci.

Envoyez un start_time pris dans slots[], et ne le calculez jamais. Service applique les horaires d’arrivée du service à la création : une heure située entre deux horaires d’arrivée, ou après le dernier, est refusée avec start_time_off_grid alors même que le service est ouvert.

Par service, six champs changent ce que vous dessinez :

Champ Ce que le tunnel en fait
slots[] Les heures réservables. available_covers: null signifie aucun plafond configuré : illimité, et non zéro.
unavailable_slots[] Des heures que ce service vend et que ce groupe ne peut pas prendre pour l’instant. Affichez-les grisées. Masquez-les et la page paraît cassée aux heures de pointe, alors que le client voit bien que le restaurant est ouvert.
effective_cutoff_minutes Le moment où la réservation en ligne ferme pour ce service à cette date, résolu à travers la cascade de règles du restaurant. C’est le compte à rebours à montrer au client. La valeur de la configuration est la valeur par défaut de la maison et peut être fausse pour le service que vous avez devant vous.
booking_closed La réservation en ligne pour ce service à cette date est terminée : sa limite de réservation est passée, ou son dernier horaire d’arrivée l’est. Ses slots sont vides parce que sa réservation est close, pas parce qu’il est plein. Retirez le service de la liste et ne l’affichez jamais comme complet.
display_only Le service mérite d’être affiché et ne prend aucune réservation en ligne. Affichez son guest_message, jamais un bouton de réservation.
requires_approval Une réservation ici arrive en pending et le personnel tranche. Ne dites jamais au client qu’il a une table.

guarantee est non nul quand le créneau demande au client de laisser une carte. Affichez le montant et les conditions d’annulation avant que le client s’engage, puis lisez Ce qu’un tunnel reconstruit ne peut pas faire avant de concevoir cette étape.

?locale= fixe la langue des textes rédigés par le restaurant dans la réponse : name du service, guest_message, day_message et texte de fermeture. Ce paramètre accepte exactement les langues de guest_languages et toute autre valeur donne un 400 nommant locale, plutôt qu’un repli silencieux sur la langue principale.

Ce paramètre concerne le texte que Service vous remet. La langue dans laquelle le client est écrit est un autre champ, guest.language sur la création, et les deux se règlent indépendamment.

Fenêtre de terminal
curl "https://api.useservice.app/v1/sections" \
-H "Authorization: Bearer $SERVICE_API_KEY"

Regroupez les lignes par group_id avant d’afficher quoi que ce soit, sinon la terrasse est proposée deux fois. Quelle ligne nomme un groupe, et quel marqueur décide de le proposer, sont sur L’objet section.

show_seating_preference dans la configuration dit si le restaurant souhaite qu’on pose la question aux clients. Ce champ est indicatif : section_id est accepté sur les disponibilités et sur une création qu’il vaille true ou false, et rien n’est refusé parce que vous en envoyez un. C’est le restaurant qui exprime une préférence sur son propre parcours de réservation et c’est le seul endroit où cette préférence existe, alors respectez-la plutôt que de trancher à sa place.

Le même identifiant sec_… part dans ?section_id= sur les disponibilités et dans section_id sur la création, et n’importe quel membre d’un groupe résout vers le groupe entier.

Fenêtre de terminal
curl -X POST "https://api.useservice.app/v1/reservations" \
-H "Authorization: Bearer $SERVICE_API_KEY" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "Content-Type: application/json" \
-d '{
"service_date": "2026-10-04",
"start_time": "20:00",
"party_size": 4,
"section_id": "sec_7Yd2Qm9",
"guest": {
"first_name": "Marie",
"last_name": "Dupont",
"email": "[email protected]",
"phone": "+33612345678",
"language": "fr"
},
"guest_notes": "Table près de la fenêtre si possible",
"metadata": { "order_ref": "PKG-40912" }
}'

Cinq remarques sur cet appel.

Idempotency-Key est obligatoire. Une valeur par tentative de réservation, la même valeur à chaque nouvel essai de cette tentative. Générez-la avant le premier appel et conservez-la pour toute la tentative, y compris l’essai qui suit un refus. Un nouvel essai d’une tentative qui a déjà abouti reçoit la réponse de cette tentative, marquée Idempotent-Replayed: true. Voir Idempotence.

Un 201 n’est pas une confirmation. Lisez status. Sous validation manuelle, la réservation arrive en pending ; sur un créneau portant une garantie par carte, elle arrive en awaiting_guarantee et ne devient valable qu’une fois la carte enregistrée par le client. Seul confirmed signifie que la table est réservée. Votre écran de confirmation a trois issues à afficher, pas une.

required_guest_fields est la liste réellement appliquée. Elle figure dans la configuration et une création à laquelle il manque l’un de ces noms est refusée avec guest_fields_required. Les deux drapeaux voisins, require_guest_email et require_guest_phone, sont indicatifs : ils décrivent ce que le restaurant demande sur son propre formulaire et cette API ne les applique pas. Servez-vous-en pour marquer le même champ obligatoire dans votre formulaire. Une création sans adresse e-mail ni numéro de téléphone est acceptée et coûte deux choses au restaurant : personne ne peut joindre ce client si le service est annulé, et la réservation ne correspond à aucun profil existant, et elle en crée un nouveau à chaque fois.

Envoyez guest.language. Sans ce champ, chaque client que vous créez est écrit dans la primary_language du restaurant : confirmation, rappel, lien de modification, enquête. Silencieusement, puisque rien dans le 201 ne dit quelle langue est partie. Le champ accepte l’une des onze langues prises en charge par Service et l’enregistre sur le profil, mais le message part dans la première de cette langue et de primary_language qui figure dans guest_languages. Lisez ce tableau pour savoir dans quelles langues le restaurant écrit vraiment. Que ces messages partent ou non relève du champ guest_notifications, voir Qui écrit au client.

Un champ que la création ne lit pas est un 400. Un champ du corps mal orthographié ou non pris en charge est refusé avec bad_request, param le nommant, au lieu d’être ignoré. Dans guest, le nom est pointé, comme guest.vip. Les clés de metadata vous appartiennent et ne sont jamais vérifiées.

Si vous connaissez déjà le client, envoyez guest_id au lieu d’un objet guest. Une création ne modifie jamais le profil et les coordonnées envoyées à côté d’un guest_id ne sont enregistrées que sur cette réservation.

Tout refus dû à une règle de réservation a une seule forme : 422, code de premier niveau booking_rules_violated et un tableau violations avec une entrée par règle enfreinte. Le tableau est là pour une règle comme pour six et le code de premier niveau ne nomme jamais une règle.

{
"error": {
"type": "invalid_request_error",
"code": "booking_rules_violated",
"violations": [
{ "code": "slot_no_longer_available", "message": "…", "param": "party_size", "overridable": true }
]
}
}

Un tunnel lit violations et non error.code. Un client qui branche sur le code de premier niveau fonctionne jusqu’au jour où une réservation enfreint deux règles à la fois.

Deux entrées méritent leur propre écran plutôt qu’un bandeau générique et elles se confondent facilement :

  • slot_no_longer_available — un service tourne et son plafond de couverts s’est rempli entre votre lecture des disponibilités et votre création. C’est la course que connaît tout tunnel. Relisez les disponibilités et montrez au client ce qui reste.
  • no_service_at_this_time — aucun service ne couvre cette date et cette heure. Relire les disponibilités et envoyer une heure qu’elles publient est le seul remède ; ce code ne peut pas faire l’objet d’un passage outre.

Refus liés aux règles de réservation documente chaque code et la signification d’overridable, et Passer outre une règle couvre le tableau override et la règle du seul nouvel essai qui l’accompagne. Un tunnel ouvert au public ne devrait en général pas porter reservations:write:override : les limites qu’il écarte sont celles du restaurant.

Un tunnel qui s’arrête au 201 laisse le client sur votre page de confirmation sans aucun retour vers sa propre réservation. Deux champs referment cet écart.

Demandez le jeton sur la création :

POST /v1/reservations?expand=modification_token

modification_token est la capacité propre du client sur cette réservation. Ajoutez-le à public_booking_url issu de la configuration et vous obtenez un lien de gestion fonctionnel — consulter, modifier, annuler — qui ne demande aucune de vos informations d’authentification :

{public_booking_url}/{modification_token}

Trois contraintes l’accompagnent :

  • Il n’est renvoyé que sur la réponse de création. Les lectures, les listes et les webhooks ne le portent pas : stockez-le à sa réception.
  • Il est conditionné à guests:read, parce qu’il authentifie l’accès aux coordonnées enregistrées du client. Une clé sans cette portée reçoit un 403 insufficient_scope avec param: "expand" et ne réserve rien : ajoutez la portée avant d’ajouter le paramètre.
  • Traitez-le comme le mot de passe du client : il ouvre tout ce que le client peut faire sur cette page, y compris l’étape carte d’une réservation à garantie. Ne le transmettez qu’au client, et ne le journalisez pas ni ne l’affichez en clair. Demander une garantie par carte détaille ce qu’il ouvre.

Lisez public_booking_url dans la configuration plutôt que de coder un hôte en dur. Cette URL est absolue à dessein, car un chemin relatif se résoudrait contre l’origine qui sert votre tunnel, et elle change si le slug du restaurant change.

Par défaut, Service écrit toujours au client. Une réservation prise par un partenaire est service_managed comme toutes les autres : l’e-mail de confirmation et le SMS du restaurant partent, et ils portent un lien de modification/annulation construit à partir de ce même jeton. Vous ne perdez cela qu’en le demandant : poser guest_notifications: "partner_managed", c’est affirmer que vous prenez en charge la communication client pour cette réservation, lien compris. Alors, et alors seulement, perdre le jeton laisse le client sans autre accès à sa réservation que vous.

guest_notifications se fixe à la création et n’en bouge plus : un PATCH qui le porte est un 400. Affirmer partner_managed supprime tous les messages client facultatifs pendant toute la vie de la réservation — confirmation, modification, annulation, rappel, enquête — sur l’e-mail comme sur le SMS. Les alertes destinées au personnel du restaurant ne sont pas touchées et vous continuez de recevoir chaque webhook reservation.*. Ce champ est refusé d’emblée sur un créneau portant une garantie par carte, avec guest_notifications_required_for_guarantee : voir Demander une garantie par carte.

Deux capacités du widget intégré n’existent pas sur cette API et les deux méritent d’être connues avant de promettre un parcours.

L’étape carte ne peut pas être reconstruite. Un créneau portant une guarantee demande au client d’enregistrer une carte. Votre clé lit le devis (mode, montant, conditions d’annulation) et jamais les informations de paiement qui sont derrière, et votre page n’a rien pour monter un formulaire de carte. La réservation arrive en awaiting_guarantee, Service envoie au client un lien de paiement par e-mail et l’étape carte se termine sur la page hébergée. Ces réservations exigent une adresse e-mail : une création sur un créneau garanti sans adresse est refusée avec email_required_for_guarantee.

Un client ne peut pas rejoindre la file d’attente depuis ici. Les disponibilités vous disent qu’une file est ouverte pour ce nombre de couverts et les points de terminaison de file d’attente sont en lecture seule. Un client qui veut la file la rejoint depuis le widget, ou le restaurant l’y inscrit depuis le back-office.