CrenoSync

Développeurs · Version 1.0.0

API de réservation

Affichez les activités, les prix et les heures libres d’une entreprise sur son propre site Web, et réservez-les, pendant que CrenoSync reste la source de vérité de l’horaire, des réservations et des paiements.

← Retour à l’accueil

En bref

  • Votre site, en directActivités, prix et heures libres lus dans CrenoSync au moment où le visiteur regarde.
  • Deux clésUne clé publiable pour les pages Web, une clé secrète pour les serveurs. Faites par le propriétaire.
  • Paiement par CrenoSyncLa carte se tape sur la page sécurisée de CrenoSync, jamais sur votre site.
  • Français et anglaisTextes, messages et courriels dans la langue du visiteur.

01Avant de commencer

L’API s’adresse à une entreprise du forfait Pro de CrenoSync qui veut afficher et réserver ses activités sur son propre site Web. Chaque entreprise a sa propre adresse, et chaque clé appartient à une seule entreprise :

  • https://{entreprise}.crenosync.ca/api/v1, où {entreprise} est le sous-domaine CrenoSync de l’entreprise (celui de son site hébergé).
  • Le propriétaire de l’entreprise fait les clés dans le portail du personnel, sous Paramètres › API, et y inscrit les sites Web qui peuvent les utiliser et les adresses où les visiteurs reviennent après avoir payé. Demandez-lui les clés ; CrenoSync n’envoie jamais de clé lui-même.

Les essais

N’essayez pas votre intégration sur l’horaire réel d’une entreprise : une réservation d’essai prendrait une vraie heure. Écrivez-nous pour obtenir une entreprise et une clé d’essai sur l’environnement de préproduction de CrenoSync, https://{entreprise}.staging.crenosync.ca/api/v1. Ses paiements utilisent les modes d’essai des fournisseurs et leurs cartes d’essai, et ses courriels vont à une boîte d’essai.

Ce que vous trouvez ici

  • Ce guide : comment les morceaux s’assemblent, quoi faire à chaque étape, et les erreurs à éviter.
  • La référence : chaque point d’accès, champ et code d’erreur, générée à partir du contrat.
  • Le fichier OpenAPI (OpenAPI 3.1), pour vos outils et vos générateurs de code.
  • Un site d’exemple en HTML et JavaScript simples qui réserve du début à la fin.

02Clés et jetons

Chaque requête porte une clé dans Authorization: Bearer …. Les étapes de réservation y ajoutent un jeton pour le visiteur.

Identifiant Où il se trouve Ce qu’il permet
Clé publiable cs_pk_ Dans vos pages Web, sur les sites inscrits avec elle Lit le catalogue et les disponibilités, ouvre les sessions de réservation
Clé secrète cs_sk_ Sur votre serveur seulement Tout ce que permet la clé publiable, et lit les réservations faites avec cette clé
Jeton d’invité cs_gt_ La tentative de réservation d’un visiteur, 60 minutes (sessionStorage, ou la session de votre serveur) Les soumissions, retenues, commandes et la réservation de ce visiteur
Jeton de retour cs_rt_ Après # sur votre page de retour, 30 minutes Lit une commande et sa réservation
  • Un navigateur peut utiliser la clé publiable seulement sur un site Web inscrit avec elle dans Paramètres › API (sinon 403 origin_not_allowed). Un serveur peut l’utiliser de n’importe où.
  • Une requête avec une clé secrète et l’en-tête Origin d’un navigateur est refusée (403 secret_key_in_browser). Ce n’est qu’un garde-fou contre une erreur : une clé secrète copiée d’une page, d’un dépôt, d’un journal ou d’un message au soutien fonctionne encore depuis un serveur. Si une clé a été exposée, le propriétaire la révoque et en fait une nouvelle tout de suite.
  • La clé secrète est affichée une seule fois, quand le propriétaire la fait. Gardez-la dans la configuration de votre serveur (une variable d’environnement), jamais dans du code que vous versez dans un dépôt.

03Conventions

  • JSON par HTTPS. Envoyez Content-Type: application/json avec un corps, et Accept: application/json.
  • Langue. Accept-Language: fr ou en ; par défaut, la langue de l’entreprise. Les textes, les messages et les courriels la suivent ; les codes ne changent jamais.
  • Les montants sont en cents avec leur devise : {"amount": 4250, "currency": "CAD"} vaut 42,50 $. Affichez les montants que CrenoSync vous donne ; ne calculez jamais un prix vous-même.
  • L’heure. Chaque entreprise vit à l’heure de l’Est (America/Toronto, nommée dans GET /site). Les dates (2026-11-14) et les heures (19:30) sont locales ; les horodatages complets portent leur décalage (2026-11-14T19:30:00-05:00).
  • Les identifiants sont des chaînes opaques avec un préfixe (act_, cat_, quo_, hld_, chk_). Conservez-les tels quels ; ne les décortiquez jamais. Une réservation se reconnaît à sa reference, le numéro de réservation que reçoit son client.
  • Request-Id. Chaque réponse en porte un. Inscrivez-le avec l’erreur dans vos journaux, et donnez-le quand vous nous écrivez.
  • Nouvelles valeurs. Des champs et des codes d’erreur peuvent s’ajouter à la v1 en tout temps : ignorez ce que vous ne connaissez pas. Certains champs sont des listes ouvertes (indiqués ainsi dans la référence) et peuvent gagner des valeurs : un statut inconnu veut dire « pas encore confirmé », un next_action inconnu veut dire « envoyez le visiteur à son url ». Les changements incompatibles iraient dans une nouvelle version.
Exemples en
JavaScriptconst API = 'https://your-business.crenosync.ca/api/v1';
const KEY = 'cs_pk_your_publishable_key';

async function api(method, path, { body, guestToken, returnToken, idempotencyKey } = {}) {
  const headers = { Authorization: `Bearer ${KEY}`, Accept: 'application/json', 'Accept-Language': 'fr' };
  if (body) headers['Content-Type'] = 'application/json';
  if (guestToken) headers['CrenoSync-Guest-Token'] = guestToken;
  if (returnToken) headers['CrenoSync-Return-Token'] = returnToken;
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const response = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
  if (response.status === 204) return null;

  const json = (response.headers.get('Content-Type') || '').includes('application/json');
  const data = json ? await response.json() : null;
  if (response.ok) return data;

  const error = new Error(data?.error?.message || `HTTP ${response.status}`);
  error.status = response.status;
  error.code = data?.error?.code || 'host_error';
  error.details = data?.error || {};
  error.retryAfter = Number(response.headers.get('Retry-After')) || null;
  error.requestId = response.headers.get('Request-Id');
  throw error;
}
Node.jsconst API = 'https://your-business.crenosync.ca/api/v1';

export async function crenosync(method, path, { body, guestToken, idempotencyKey } = {}) {
  const headers = {
    Authorization: `Bearer ${process.env.CRENOSYNC_SECRET_KEY}`,
    Accept: 'application/json',
    'Accept-Language': 'fr',
  };
  if (body) headers['Content-Type'] = 'application/json';
  if (guestToken) headers['CrenoSync-Guest-Token'] = guestToken;
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

  const response = await fetch(API + path, {
    method,
    headers,
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(20000),
  });
  if (response.status === 204) return null;

  const json = (response.headers.get('Content-Type') || '').includes('application/json');
  const data = json ? await response.json() : null;
  if (response.ok) return data;

  const error = new Error(data?.error?.message || `HTTP ${response.status}`);
  error.status = response.status;
  error.code = data?.error?.code || 'host_error';
  error.details = data?.error || {};
  error.retryAfter = Number(response.headers.get('Retry-After')) || null;
  error.requestId = response.headers.get('Request-Id');
  throw error;
}
PHP<?php

const CRENOSYNC_API = 'https://your-business.crenosync.ca/api/v1';

final class CrenoSyncException extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $errorCode,
        public readonly array $details,
        public readonly ?int $retryAfter,
        public readonly ?string $requestId,
    ) {
        parent::__construct($details['message'] ?? "HTTP $status");
    }
}

function crenosync(string $method, string $path, ?array $body = null, array $headers = []): ?array
{
    $headers[] = 'Authorization: Bearer ' . getenv('CRENOSYNC_SECRET_KEY');
    $headers[] = 'Accept: application/json';
    $headers[] = 'Accept-Language: fr';
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
    }

    $curl = curl_init(CRENOSYNC_API . $path);
    curl_setopt_array($curl, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_HTTPHEADER => $headers,
        CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 20,
        CURLOPT_HEADER => true,
    ]);
    $raw = curl_exec($curl);
    if ($raw === false) {
        throw new CrenoSyncException(0, 'host_error', ['message' => 'CrenoSync unreachable: ' . curl_error($curl)], null, null);
    }
    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
    $size = curl_getinfo($curl, CURLINFO_HEADER_SIZE);
    $head = substr($raw, 0, $size);
    $data = json_decode(substr($raw, $size), true);
    if ($status < 400) {
        return $data;
    }

    preg_match('/^Retry-After:\s*(\d+)/mi', $head, $wait);
    preg_match('/^Request-Id:\s*(\S+)/mi', $head, $id);
    $error = is_array($data['error'] ?? null) ? $data['error'] : ['code' => 'host_error', 'message' => "HTTP $status"];
    throw new CrenoSyncException($status, $error['code'], $error, isset($wait[1]) ? (int) $wait[1] : null, $id[1] ?? null);
}
Pythonimport os

import requests

API = "https://your-business.crenosync.ca/api/v1"


class CrenoSyncError(Exception):
    def __init__(self, status, error, retry_after, request_id):
        super().__init__(error.get("message", f"HTTP {status}"))
        self.status = status
        self.code = error.get("code", "host_error")
        self.details = error
        self.retry_after = retry_after
        self.request_id = request_id


def crenosync(method, path, body=None, params=None, guest_token=None, idempotency_key=None):
    headers = {
        "Authorization": f"Bearer {os.environ['CRENOSYNC_SECRET_KEY']}",
        "Accept": "application/json",
        "Accept-Language": "fr",
    }
    if guest_token:
        headers["CrenoSync-Guest-Token"] = guest_token
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    response = requests.request(method, API + path, json=body, params=params, headers=headers, timeout=20)
    if response.status_code == 204:
        return None

    data = response.json() if "application/json" in response.headers.get("Content-Type", "") else None
    if response.ok:
        return data

    wait = response.headers.get("Retry-After", "")
    raise CrenoSyncError(response.status_code, (data or {}).get("error") or {},
                         int(wait) if wait.isdigit() else None, response.headers.get("Request-Id"))
curlexport CRENOSYNC_API='https://your-business.crenosync.ca/api/v1'
printf 'Secret key: '; read -rs CRENOSYNC_SECRET_KEY; echo; export CRENOSYNC_SECRET_KEY

curl -sS -i "$CRENOSYNC_API/site" \
  -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" \
  -H 'Accept: application/json' \
  -H 'Accept-Language: fr'

Les exemples de cette page utilisent ces petites fonctions : api() dans une page Web avec la clé publiable, et crenosync() sur un serveur avec la clé secrète, lue dans la variable d’environnement CRENOSYNC_SECRET_KEY. Chacune lance une erreur qui porte le code de l’API, ses details, le délai Retry-After et le Request-Id (en PHP, une CrenoSyncException, son code dans errorCode) ; un exemple s’arrête donc à un refus, sauf s’il le traite.

  • Node.js 20 ou plus récent, sans paquet : les exemples s’exécutent dans une route de votre cadriciel, avec req.session et res comme dans Express.
  • Python avec requests : les exemples s’exécutent dans une vue, avec session et request comme dans Flask (le request.session de Django fonctionne de la même façon).
  • curl 7.76 ou plus récent pour essayer l’API depuis un terminal : les commandes gardent les identifiants dans des variables du shell avec jq. Avec --fail-with-body, un refus est affiché et laisse vides les variables de l’étape : arrêtez-vous là et corrigez-le avant l’étape suivante.

Les exemples prennent la première activité de l’entreprise, sa première catégorie de prix, le plus petit groupe et la première heure libre des 7 prochains jours. Sur votre page, c’est le visiteur qui les choisit : les exemples serveur lisent ce choix dans le formulaire (activity_id, date, time, category_id, quantity, difficulty_level), et les identifiants du prix, de la retenue et du paiement passent d’une requête à l’autre dans la session du visiteur.

04Le parcours de réservation

Une réservation passe par six étapes. Les disponibilités sont un instantané : une heure affichée libre peut être prise par quelqu’un d’autre tant qu’elle n’est pas retenue, et seule une commande confirmed est une réservation.

1. Afficher les activités et leurs heures libres

GET /activities liste ce qui peut être réservé, avec ses catégories de prix (adulte, enfant…), les tailles de groupe et les niveaux de difficulté, s’il y en a. GET /activities/{id}/availability donne les heures libres pour une taille de groupe sur 31 jours au plus. Demandez-les quand le visiteur ouvre le calendrier ou change la date ou le groupe, pas à intervalles réguliers.

Exemples en
JavaScriptconst { data: activities } = await api('GET', '/activities');
const activity = activities[0];

const dateIn = (days) => new Date(Date.now() + days * 86400000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' });
const query = new URLSearchParams({ from: dateIn(0), to: dateIn(6), group_size: String(activity.group.min) });
const availability = await api('GET', `/activities/${activity.id}/availability?${query}`);

for (const day of availability.days) {
  for (const time of day.times) {
    console.log(day.date, time.time, time.places_left);
  }
}
const open = availability.days.find((day) => day.times.length > 0);
const choice = open && { date: open.date, time: open.times[0].time, quantity: activity.group.min };
Node.jsconst { data: activities } = await crenosync('GET', '/activities');
const activity = activities[0];

const dateIn = (days) => new Date(Date.now() + days * 86400000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' });
const query = new URLSearchParams({ from: dateIn(0), to: dateIn(6), group_size: String(activity.group.min) });
const availability = await crenosync('GET', `/activities/${activity.id}/availability?${query}`);

for (const day of availability.days) {
  for (const time of day.times) {
    console.log(day.date, time.time, time.places_left);
  }
}
PHP<?php

$activities = crenosync('GET', '/activities')['data'];
$activity = $activities[0];

$today = new DateTimeImmutable('today', new DateTimeZone('America/Toronto'));
$availability = crenosync('GET', '/activities/' . rawurlencode($activity['id']) . '/availability?' . http_build_query([
    'from' => $today->format('Y-m-d'),
    'to' => $today->modify('+6 days')->format('Y-m-d'),
    'group_size' => $activity['group']['min'],
]));

foreach ($availability['days'] as $day) {
    foreach ($day['times'] as $time) {
        echo $day['date'], ' ', $time['time'], "\n";
    }
}
Pythonfrom datetime import datetime, timedelta
from zoneinfo import ZoneInfo

activities = crenosync("GET", "/activities")["data"]
activity = activities[0]

today = datetime.now(ZoneInfo("America/Toronto")).date()
availability = crenosync("GET", f"/activities/{activity['id']}/availability", params={
    "from": today.isoformat(),
    "to": (today + timedelta(days=6)).isoformat(),
    "group_size": activity["group"]["min"],
})

for day in availability["days"]:
    for slot in day["times"]:
        print(day["date"], slot["time"], slot["places_left"])
curlunset ACTIVITY CATEGORY LEVEL GROUP DATE TIME; ANSWER=
ANSWER=$(curl -sS --fail-with-body "$CRENOSYNC_API/activities" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json') \
  && ACTIVITY=$(jq -r '.data[0].id' <<<"$ANSWER") \
  && CATEGORY=$(jq -r '.data[0].price_categories[0].id' <<<"$ANSWER") \
  && LEVEL=$(jq '.data[0].difficulty.levels[0].level // null' <<<"$ANSWER") \
  && GROUP=$(jq '.data[0].group.min' <<<"$ANSWER") \
  && FROM=$(TZ=America/Toronto jq -nr 'now | localtime | strftime("%Y-%m-%d")') \
  && TO=$(TZ=America/Toronto jq -nr 'now + 6 * 86400 | localtime | strftime("%Y-%m-%d")') \
  && ANSWER=$(curl -sS --fail-with-body -G "$CRENOSYNC_API/activities/$ACTIVITY/availability" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json' \
    --data-urlencode "from=$FROM" --data-urlencode "to=$TO" --data-urlencode "group_size=$GROUP") \
  && jq -r '.days[] | .date as $date | .times[] | "\($date) \(.time) \(.places_left)"' <<<"$ANSWER" \
  && DATE=$(jq -r '[.days[] | select(.times | length > 0)][0].date // empty' <<<"$ANSWER") \
  && TIME=$(jq -r '[.days[] | select(.times | length > 0)][0].times[0].time // empty' <<<"$ANSWER") \
  || echo "$ANSWER"

Dans la référence, avec leur requête et leur réponse complètes : Lister les activités, Heures libres.

2. Ouvrir une session de réservation

Quand le visiteur commence à réserver (pas au chargement de la page), POST /booking-sessions donne un jeton d’invité pour ce visiteur seul. Envoyez-le comme CrenoSync-Guest-Token à chacune des étapes suivantes. Il dure 60 minutes et permet quelques retenues à la fois (limits.holds).

Dans la référence, avec leur requête et leur réponse complètes : Ouvrir une session de réservation.

3. Obtenir le prix

POST /quotes vérifie le groupe selon les règles de l’activité et donne le prix exact : chaque ligne, le rabais d’un code promotionnel, les taxes, le total, ce qui est dû maintenant (due_now : le dépôt, ou le prix complet quand l’activité le demande, comme l’indique payment) et sur place. Affichez-le tel quel. Une soumission est valide 15 minutes et ne réserve rien.

Dans la référence, avec leur requête et leur réponse complètes : Calculer le prix d’une réservation.

4. Retenir l’heure

POST /holds avec la soumission prend l’heure (ou les places du groupe dans une séance partagée) pendant que le visiteur entre ses coordonnées : 10 minutes par défaut, sans renouvellement. Envoyez une Idempotency-Key (un UUID) pour qu’une nouvelle tentative ne retienne jamais deux fois. Si le visiteur revient en arrière ou choisit une autre heure, libérez la retenue avec DELETE /holds/{id}.

Exemples en
JavaScriptlet guestToken = null;
try { guestToken = sessionStorage.getItem('crenosync_guest'); } catch (blocked) { /* kept in memory only */ }
if (!guestToken) {
  const session = await api('POST', '/booking-sessions');
  guestToken = session.token;
  try { sessionStorage.setItem('crenosync_guest', guestToken); } catch (blocked) { /* kept in memory only */ }
}

const quote = await api('POST', '/quotes', {
  guestToken,
  body: {
    activity_id: activity.id,
    date: choice.date,
    time: choice.time,
    participants: [{ category_id: activity.price_categories[0].id, quantity: choice.quantity }],
    difficulty_level: activity.difficulty ? activity.difficulty.levels[0].level : null,
    promo_code: null,
  },
});

const hold = await api('POST', '/holds', {
  guestToken,
  idempotencyKey: crypto.randomUUID(),
  body: { quote_id: quote.id },
});
Node.jsconst { token } = await crenosync('POST', '/booking-sessions');
req.session.guestToken = token;

const choice = req.body;
const quote = await crenosync('POST', '/quotes', {
  guestToken: req.session.guestToken,
  body: {
    activity_id: choice.activity_id,
    date: choice.date,
    time: choice.time,
    participants: [{ category_id: choice.category_id, quantity: Number(choice.quantity) }],
    difficulty_level: choice.difficulty_level ? Number(choice.difficulty_level) : null,
  },
});

const hold = await crenosync('POST', '/holds', {
  guestToken: req.session.guestToken,
  idempotencyKey: crypto.randomUUID(),
  body: { quote_id: quote.id },
});
req.session.quoteId = quote.id;
req.session.holdId = hold.id;
PHP<?php

$_SESSION['guest_token'] = crenosync('POST', '/booking-sessions')['token'];
$guest = ['CrenoSync-Guest-Token: ' . $_SESSION['guest_token']];

$quote = crenosync('POST', '/quotes', [
    'activity_id' => $_POST['activity_id'],
    'date' => $_POST['date'],
    'time' => $_POST['time'],
    'participants' => [['category_id' => $_POST['category_id'], 'quantity' => (int) $_POST['quantity']]],
    'difficulty_level' => ($_POST['difficulty_level'] ?? '') === '' ? null : (int) $_POST['difficulty_level'],
], $guest);

$hold = crenosync('POST', '/holds', ['quote_id' => $quote['id']],
    [...$guest, 'Idempotency-Key: ' . bin2hex(random_bytes(16))]);
$_SESSION['quote_id'] = $quote['id'];
$_SESSION['hold_id'] = $hold['id'];
Pythonimport uuid

session["guest_token"] = crenosync("POST", "/booking-sessions")["token"]

choice = request.form
quote = crenosync("POST", "/quotes", guest_token=session["guest_token"], body={
    "activity_id": choice["activity_id"],
    "date": choice["date"],
    "time": choice["time"],
    "participants": [{"category_id": choice["category_id"], "quantity": int(choice["quantity"])}],
    "difficulty_level": int(choice["difficulty_level"]) if choice.get("difficulty_level") else None,
})

hold = crenosync("POST", "/holds", guest_token=session["guest_token"],
                 idempotency_key=str(uuid.uuid4()), body={"quote_id": quote["id"]})
session["quote_id"] = quote["id"]
session["hold_id"] = hold["id"]
curlunset GUEST QUOTE HOLD; ANSWER=
: "${TIME:?no open time: run the catalog step first}" \
  && ANSWER=$(curl -sS --fail-with-body -X POST "$CRENOSYNC_API/booking-sessions" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json') \
  && GUEST=$(jq -r '.token' <<<"$ANSWER") \
  && ANSWER=$(curl -sS --fail-with-body -X POST "$CRENOSYNC_API/quotes" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H "CrenoSync-Guest-Token: $GUEST" \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    --data "$(jq -n --arg activity "$ACTIVITY" --arg category "$CATEGORY" --argjson level "$LEVEL" \
      --arg date "$DATE" --arg time "$TIME" --argjson group "$GROUP" '{
      activity_id: $activity, date: $date, time: $time,
      participants: [{category_id: $category, quantity: $group}],
      difficulty_level: $level
    }')") \
  && QUOTE=$(jq -r '.id' <<<"$ANSWER") \
  && ANSWER=$(curl -sS --fail-with-body -X POST "$CRENOSYNC_API/holds" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H "CrenoSync-Guest-Token: $GUEST" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    --data "{\"quote_id\": \"$QUOTE\"}") \
  && HOLD=$(jq -r '.id' <<<"$ANSWER") \
  || echo "$ANSWER"

Dans la référence, avec leur requête et leur réponse complètes : Retenir une heure.

5. Passer la commande

Affichez la politique de confidentialité et les conditions de GET /policies à côté des cases d’acceptation, puis envoyez POST /checkouts avec la retenue, la soumission que le visiteur a vue, son nom, son courriel et son téléphone, les versions des politiques acceptées et votre return_url. Le status de la réponse dit la suite :

  • confirmed : réservé. Rien à payer en ligne, aucun code à taper.
  • awaiting_verification : l’entreprise confirme ces réservations par un code à 6 chiffres envoyé par courriel (voir plus bas).
  • awaiting_payment : envoyez le visiteur à next_action.url, la page de paiement de CrenoSync (voir plus bas).

Si le prix a changé depuis la soumission, la réponse est 409 quote_changed avec la nouvelle soumission : affichez-la, et envoyez la commande de nouveau avec l’identifiant de la nouvelle soumission, la même retenue et une nouvelle Idempotency-Key. Tant qu’une première requête pour la même retenue est encore en cours (un double clic, un deuxième onglet), la réponse est 409 checkout_in_progress : attendez le délai de Retry-After et envoyez-la de nouveau ; la commande déjà ouverte vous revient.

Exemples en
JavaScriptconst policies = await api('GET', '/policies');

async function checkOut(quoteId) {
  const body = {
    hold_id: hold.id,
    quote_id: quoteId,
    customer: { name: 'Camille Tremblay', email: 'camille@example.com', phone: '514 555-0199', language: 'fr' },
    consent: {
      privacy_version_id: policies.privacy.version_id,
      privacy_accepted: true,
      terms_version_id: policies.terms ? policies.terms.version_id : null,
      terms_accepted: policies.terms ? true : null,
    },
    return_url: 'https://www.example.ca/reservation/merci',
  };
  const idempotencyKey = crypto.randomUUID();

  for (let attempt = 1; ; attempt++) {
    try {
      return await api('POST', '/checkouts', { guestToken, idempotencyKey, body });
    } catch (error) {
      const running = error.code === 'checkout_in_progress' || error.code === 'idempotency_in_progress';
      if (running && attempt < 5) {
        await new Promise((done) => setTimeout(done, Math.min(error.retryAfter || 2, 10) * 1000));
        continue;
      }
      if (error.code === 'quote_changed' && confirm(`New price: ${error.details.quote.total.amount / 100} $`)) {
        return checkOut(error.details.quote.id);
      }
      throw error;
    }
  }
}

const checkout = await checkOut(quote.id);
if (checkout.status === 'confirmed') {
  showBooking(checkout.booking.reference);
} else if (checkout.next_action && checkout.next_action.type === 'verify_email') {
  askForCode(checkout);
} else if (checkout.next_action) {
  window.location.assign(checkout.next_action.url);
}
Node.jsconst policies = await crenosync('GET', '/policies');
const body = {
  hold_id: req.session.holdId,
  quote_id: req.session.quoteId,
  customer: { name: 'Camille Tremblay', email: 'camille@example.com', phone: '514 555-0199', language: 'fr' },
  consent: {
    privacy_version_id: policies.privacy.version_id,
    privacy_accepted: true,
    terms_version_id: policies.terms ? policies.terms.version_id : null,
    terms_accepted: policies.terms ? true : null,
  },
  return_url: 'https://www.example.ca/reservation/merci',
  external_reference: 'order-1042',
};
const idempotencyKey = crypto.randomUUID();

let checkout = null;
for (let attempt = 1; !checkout; attempt++) {
  try {
    checkout = await crenosync('POST', '/checkouts', { guestToken: req.session.guestToken, idempotencyKey, body });
  } catch (error) {
    const running = error.code === 'checkout_in_progress' || error.code === 'idempotency_in_progress';
    if (running && attempt < 5) {
      await new Promise((done) => setTimeout(done, Math.min(error.retryAfter || 2, 10) * 1000));
    } else if (error.code === 'quote_changed') {
      req.session.quoteId = error.details.quote.id;
      return res.status(409).json({ new_price: error.details.quote.total });
    } else {
      throw error;
    }
  }
}

req.session.checkoutId = checkout.id;
if (checkout.status === 'confirmed') {
  return res.json({ booked: checkout.booking.reference });
}
if (checkout.next_action?.url) {
  return res.redirect(303, checkout.next_action.url);
}
res.status(202).json({ status: checkout.status });
PHP<?php

$guest = ['CrenoSync-Guest-Token: ' . $_SESSION['guest_token']];
$policies = crenosync('GET', '/policies');
$body = [
    'hold_id' => $_SESSION['hold_id'],
    'quote_id' => $_SESSION['quote_id'],
    'customer' => ['name' => 'Camille Tremblay', 'email' => 'camille@example.com', 'phone' => '514 555-0199', 'language' => 'fr'],
    'consent' => [
        'privacy_version_id' => $policies['privacy']['version_id'],
        'privacy_accepted' => true,
        'terms_version_id' => $policies['terms']['version_id'] ?? null,
        'terms_accepted' => isset($policies['terms']) ? true : null,
    ],
    'return_url' => 'https://www.example.ca/reservation/merci',
    'external_reference' => 'order-1042',
];
$idempotencyKey = 'Idempotency-Key: ' . bin2hex(random_bytes(16));

for ($attempt = 1; ; $attempt++) {
    try {
        $checkout = crenosync('POST', '/checkouts', $body, [...$guest, $idempotencyKey]);
        break;
    } catch (CrenoSyncException $refused) {
        $running = in_array($refused->errorCode, ['checkout_in_progress', 'idempotency_in_progress'], true);
        if ($running && $attempt < 5) {
            sleep(min($refused->retryAfter ?? 2, 10));
            continue;
        }
        if ($refused->errorCode === 'quote_changed') {
            $_SESSION['quote_id'] = $refused->details['quote']['id'];
            http_response_code(409);
            printf('New price: %.2f $. Confirm again to book at this price.', $refused->details['quote']['total']['amount'] / 100);
            exit;
        }
        throw $refused;
    }
}

$_SESSION['checkout_id'] = $checkout['id'];
if ($checkout['status'] === 'confirmed') {
    echo 'Booked: ', htmlspecialchars($checkout['booking']['reference']);
} elseif (isset($checkout['next_action']['url'])) {
    header('Location: ' . $checkout['next_action']['url'], true, 303);
} else {
    http_response_code(202);
    echo 'Not confirmed yet: ', htmlspecialchars($checkout['status']);
}
Pythonimport time
import uuid

policies = crenosync("GET", "/policies")
terms = policies.get("terms")
body = {
    "hold_id": session["hold_id"],
    "quote_id": session["quote_id"],
    "customer": {"name": "Camille Tremblay", "email": "camille@example.com", "phone": "514 555-0199", "language": "fr"},
    "consent": {
        "privacy_version_id": policies["privacy"]["version_id"],
        "privacy_accepted": True,
        "terms_version_id": terms["version_id"] if terms else None,
        "terms_accepted": True if terms else None,
    },
    "return_url": "https://www.example.ca/reservation/merci",
    "external_reference": "order-1042",
}
idempotency_key = str(uuid.uuid4())

checkout = None
attempt = 0
while checkout is None:
    attempt += 1
    try:
        checkout = crenosync("POST", "/checkouts", guest_token=session["guest_token"],
                             idempotency_key=idempotency_key, body=body)
    except CrenoSyncError as error:
        if error.code in ("checkout_in_progress", "idempotency_in_progress") and attempt < 5:
            time.sleep(min(error.retry_after or 2, 10))
        elif error.code == "quote_changed":
            session["quote_id"] = error.details["quote"]["id"]
            return {"new_price": error.details["quote"]["total"]}, 409
        else:
            raise

session["checkout_id"] = checkout["id"]
if checkout["status"] == "confirmed":
    return {"booked": checkout["booking"]["reference"]}
next_action = checkout.get("next_action") or {}
if "url" in next_action:
    return redirect(next_action["url"], code=303)
return {"status": checkout["status"]}, 202
curlunset CHECKOUT_ID; ANSWER=
: "${HOLD:?no hold: run the hold step first}" \
  && ANSWER=$(curl -sS --fail-with-body "$CRENOSYNC_API/policies" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json') \
  && POLICIES=$ANSWER \
  && ANSWER=$(curl -sS --fail-with-body -X POST "$CRENOSYNC_API/checkouts" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H "CrenoSync-Guest-Token: $GUEST" \
    -H "Idempotency-Key: $(uuidgen)" \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    --data "$(jq -n --arg hold "$HOLD" --arg quote "$QUOTE" --argjson policies "$POLICIES" '{
      hold_id: $hold,
      quote_id: $quote,
      customer: {name: "Camille Tremblay", email: "camille@example.com", phone: "514 555-0199", language: "fr"},
      consent: {
        privacy_version_id: $policies.privacy.version_id,
        privacy_accepted: true,
        terms_version_id: $policies.terms.version_id,
        terms_accepted: (if $policies.terms then true else null end)
      },
      return_url: "https://www.example.ca/reservation/merci",
      external_reference: "order-1042"
    }')") \
  && CHECKOUT_ID=$(jq -r '.id' <<<"$ANSWER")
jq '{id, status, next_action, error}' <<<"$ANSWER" 2>/dev/null || echo "$ANSWER"

Dans la référence, avec leur requête et leur réponse complètes : Les politiques en vigueur, Passer la commande, Confirmer avec le code par courriel.

6. Afficher la réservation

GET /bookings/{reference} donne la réservation : activité, date, heure, groupe, prix, ce qui a été payé et ce qui reste à payer sur place. Le client reçoit aussi le courriel de confirmation de CrenoSync, dans sa langue.

Dans la référence, avec leur requête et leur réponse complètes : Lire une réservation.

05Le paiement et le retour

Quand un montant est dû en ligne, le visiteur paie sur la page de CrenoSync, avec le fournisseur de paiement de l’entreprise. Les numéros de carte ne passent jamais par votre site, et vous n’avez rien à faire avec le fournisseur.

  • Envoyez le visiteur à next_action.url dans toute la fenêtre, jamais dans un cadre ni une fenêtre surgissante. C’est un lien signé pour cette commande seulement, valide jusqu’à next_action.expires_at : ne le conservez pas, ne l’inscrivez dans aucun journal et ne l’envoyez nulle part ailleurs. Le premier navigateur qui l’ouvre garde la commande ; un autre navigateur est refusé.
  • La fenêtre de paiement dure 30 minutes, écourtée pour finir 15 minutes avant le début de l’activité, mais jamais moins de 10 minutes. L’heure reste retenue 5 minutes de plus pour la confirmation du paiement. Un paiement fait pendant la fenêtre est réservé, même si l’activité vient de commencer. Le expires_at de la commande donne la fin de la fenêtre.
  • Le formulaire de certains fournisseurs peut expirer avant la fin de la fenêtre. La page de CrenoSync offre alors au visiteur un nouveau formulaire pour la même commande ; vous n’avez rien à faire.

Une fois le paiement fait, le visiteur revient à votre return_url avec l’identifiant de la commande et un jeton de retour après # : https://www.example.ca/reservation/merci#checkout_id=chk_…&return_token=cs_rt_…. Les navigateurs n’envoient jamais cette partie à votre serveur, à ses journaux ni à d’autres sites. Lisez-la dans le script de la page, retirez-la de l’adresse avec history.replaceState avant tout script de statistiques, et lisez la commande avec elle :

JavaScript
JavaScriptconst fragment = new URLSearchParams(window.location.hash.slice(1));
const checkoutId = fragment.get('checkout_id');
const returnToken = fragment.get('return_token');
history.replaceState(null, '', window.location.pathname + window.location.search);

const read = () => api('GET', `/checkouts/${checkoutId}`, { returnToken });
let checkout = await read();
for (let wait = 2; checkout.status === 'processing' && wait <= 32; wait *= 2) {
  await new Promise((done) => setTimeout(done, wait * 1000));
  checkout = await read();
}

if (checkout.status === 'confirmed') {
  const booking = await api('GET', `/bookings/${checkout.booking.reference}`, { returnToken });
  showBooking(booking);
} else if (checkout.status === 'processing' || checkout.status === 'attention_required') {
  showMessage('Your payment is being confirmed. You will receive an email.');
} else {
  showMessage('The booking was not completed. You can start again.');
}
  • processing veut dire que le paiement est en cours de confirmation auprès du fournisseur : relisez la commande après 2, 4, 8 secondes… pendant une minute ou deux. Si elle l’est encore, dites au visiteur qu’un courriel suivra. Ne lui demandez jamais de payer de nouveau.
  • attention_required veut dire que le visiteur a payé, mais que la réservation n’a pas pu se faire automatiquement (l’heure a été prise entre-temps, par exemple). L’entreprise a été avertie et communiquera avec lui. Ne lui demandez jamais de payer de nouveau non plus.
  • L’adresse de retour doit être l’une de celles inscrites avec la clé, en https (http seulement pour un hôte de développement local). Envoyez Referrer-Policy: no-referrer sur cette page.
  • Une intégration côté serveur n’a pas besoin du jeton de retour : elle lit la commande qu’elle a ouverte avec sa clé secrète (voir plus bas).

Une commande reste confirmed une fois réservée. Si l’entreprise annule ensuite la réservation dans son portail, la commande ne change pas : lisez le status de la réservation elle-même (confirmed ou cancelled) pour savoir où elle en est.

06Le code par courriel

Une entreprise peut confirmer les réservations sans rien à payer en ligne par un code à 6 chiffres envoyé au courriel du visiteur. La commande répond alors awaiting_verification, avec next_action.email_hint (l’adresse, en partie masquée) et attempts_left. Demandez le code sur votre propre page, ou envoyez le visiteur à next_action.url pour le taper sur la page de CrenoSync, qui se termine sur votre return_url comme un paiement.

Exemples en
JavaScripttry {
  const checkout = await api('POST', `/checkouts/${checkoutId}/verify-email`, {
    guestToken,
    body: { code: document.querySelector('#code').value.trim() },
  });
  showBooking(checkout.booking.reference);
} catch (error) {
  if (error.code === 'verification_failed') {
    showMessage(`${error.message} (${error.details.attempts_left})`);
  } else {
    throw error;
  }
}
Node.jstry {
  const checkout = await crenosync('POST', `/checkouts/${req.session.checkoutId}/verify-email`, {
    guestToken: req.session.guestToken,
    body: { code: String(req.body.code).trim() },
  });
  res.json({ booking: checkout.booking.reference });
} catch (error) {
  if (error.code !== 'verification_failed') throw error;
  res.status(422).json({ message: error.message, attempts_left: error.details.attempts_left });
}
PHP<?php

$guest = ['CrenoSync-Guest-Token: ' . $_SESSION['guest_token']];
try {
    $checkout = crenosync('POST', '/checkouts/' . rawurlencode($_SESSION['checkout_id']) . '/verify-email',
        ['code' => trim((string) ($_POST['code'] ?? ''))], $guest);
    echo 'Booked: ', htmlspecialchars($checkout['booking']['reference']);
} catch (CrenoSyncException $refused) {
    if ($refused->errorCode !== 'verification_failed') {
        throw $refused;
    }
    http_response_code(422);
    echo htmlspecialchars($refused->getMessage()), ' (', (int) $refused->details['attempts_left'], ')';
}
Pythontry:
    checkout = crenosync("POST", f"/checkouts/{session['checkout_id']}/verify-email",
                         guest_token=session["guest_token"], body={"code": request.form["code"].strip()})
except CrenoSyncError as error:
    if error.code != "verification_failed":
        raise
    return {"message": str(error), "attempts_left": error.details["attempts_left"]}, 422

return {"booking": checkout["booking"]["reference"]}
curl: "${CHECKOUT_ID:?no checkout: run the checkout step first}" \
  && curl -sS --fail-with-body -X POST "$CRENOSYNC_API/checkouts/$CHECKOUT_ID/verify-email" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H "CrenoSync-Guest-Token: $GUEST" \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    --data '{"code": "123456"}'
  • Cinq mauvais codes annulent la commande et libèrent l’heure (409 verification_locked).
  • POST /checkouts/{id}/resend-code envoie un nouveau code : trois par commande au plus, un par minute.
  • Le code fonctionne jusqu’à la fin de la retenue ; ensuite la commande est expired.

07Erreurs et nouvelles tentatives

Chaque erreur a la même forme, avec un code stable pour votre programme et un message pour les personnes, dans la langue de la requête. Testez le code, jamais le message.

Réponse Quoi faire
401, 403 Un problème de clé, de jeton ou de site Web (invalid_key, origin_not_allowed, guest_token_expired…). Corrigez la configuration, ou ouvrez une nouvelle session de réservation.
404 not_found Rien avec cet identifiant pour cette clé ou ce jeton. Ce qui appartient à un autre visiteur, une autre clé ou une autre entreprise répond de la même façon.
409 La situation a changé : time_unavailable (choisissez une autre heure), hold_expired (recommencez à partir des disponibilités), quote_changed et policy_changed (affichez le nouveau prix ou les nouveaux textes, puis envoyez de nouveau). checkout_in_progress et idempotency_in_progress : attendez le délai de Retry-After et envoyez la même requête de nouveau.
422 La requête enfreint une règle : validation_failed avec les messages de chaque champ dans fields, ou une règle de réservation (group_invalid, promo_code_invalid, return_url_not_allowed…). Affichez le message à côté du champ.
429 Trop de requêtes, de retenues ou de codes. Attendez le délai de Retry-After (en secondes) avant de réessayer.
500, 503 Une défaillance du côté de CrenoSync, ou l’entreprise est indisponible un moment. Réessayez plus tard ; donnez le Request-Id.

L’idempotence

POST /holds et POST /checkouts demandent un en-tête Idempotency-Key, un UUID que vous faites pour chaque opération. Renvoyer la même clé et le même corps dans les 24 heures redonne la première réponse (Idempotent-Replayed: true) au lieu d’une deuxième retenue ou commande. Après un délai dépassé ou une erreur réseau, réessayez donc avec la même clé ; après avoir changé le corps (une nouvelle soumission), utilisez-en une nouvelle.

Les réponses qui ne viennent pas de CrenoSync

Un 502, 503 ou 504 peut venir du serveur Web de l’hébergeur, devant CrenoSync, sous forme de page HTML sans Request-Id. Traitez-le comme toute défaillance passagère : attendez quelques secondes et envoyez la même requête de nouveau, avec la même Idempotency-Key. Un 429 de la limite par adresse refuse aussi les requêtes préalables (preflight) du navigateur, qui les signale comme une erreur réseau.

Exemples en
JavaScriptasync function withRetries(send, attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await send();
    } catch (error) {
      const temporary = error.status === 429 || error.status >= 500 || error instanceof TypeError;
      if (!temporary || attempt === attempts) throw error;
      const seconds = error.retryAfter || 2 ** attempt;
      await new Promise((done) => setTimeout(done, seconds * 1000));
    }
  }
}

const idempotencyKey = crypto.randomUUID();
const hold = await withRetries(() =>
  api('POST', '/holds', { guestToken, idempotencyKey, body: { quote_id: quote.id } }));
Node.jsasync function withRetries(send, attempts = 4) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await send();
    } catch (error) {
      const unreachable = error instanceof TypeError || error.name === 'TimeoutError';
      const temporary = error.status === 429 || error.status >= 500 || unreachable;
      if (!temporary || attempt === attempts) throw error;
      const seconds = error.retryAfter || 2 ** attempt;
      await new Promise((done) => setTimeout(done, seconds * 1000));
    }
  }
}

const idempotencyKey = crypto.randomUUID();
const hold = await withRetries(() =>
  crenosync('POST', '/holds', { guestToken: req.session.guestToken, idempotencyKey, body: { quote_id: quote.id } }));
PHP<?php

function withRetries(callable $send, int $attempts = 4): ?array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $send();
        } catch (CrenoSyncException $failure) {
            $temporary = $failure->status === 0 || $failure->status === 429 || $failure->status >= 500;
            if (!$temporary || $attempt === $attempts) {
                throw $failure;
            }
            sleep($failure->retryAfter ?? 2 ** $attempt);
        }
    }
}

$idempotencyKey = 'Idempotency-Key: ' . bin2hex(random_bytes(16));
$hold = withRetries(fn () => crenosync('POST', '/holds', ['quote_id' => $quote['id']], [...$guest, $idempotencyKey]));
Pythonimport time
import uuid

import requests


def with_retries(send, attempts=4):
    for attempt in range(1, attempts + 1):
        try:
            return send()
        except CrenoSyncError as error:
            if (error.status != 429 and error.status < 500) or attempt == attempts:
                raise
            time.sleep(error.retry_after or 2 ** attempt)
        except (requests.ConnectionError, requests.Timeout):
            if attempt == attempts:
                raise
            time.sleep(2 ** attempt)


idempotency_key = str(uuid.uuid4())
hold = with_retries(lambda: crenosync("POST", "/holds", guest_token=session["guest_token"],
                                      idempotency_key=idempotency_key, body={"quote_id": quote["id"]}))
curlIDEMPOTENCY_KEY=$(uuidgen)

: "${QUOTE:?no quote: run the hold step first}" \
  && curl -sS --fail-with-body --retry 4 --retry-connrefused -X POST "$CRENOSYNC_API/holds" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H "CrenoSync-Guest-Token: $GUEST" \
    -H "Idempotency-Key: $IDEMPOTENCY_KEY" \
    -H 'Accept: application/json' -H 'Content-Type: application/json' \
    --data "{\"quote_id\": \"$QUOTE\"}"

08Depuis votre serveur

Un site Web qui réserve depuis son propre serveur utilise la clé secrète et les mêmes étapes. Il ouvre une session de réservation par visiteur (les limites de retenues comptent par jeton d’invité), garde le jeton d’invité dans la session de ce visiteur, et n’a pas besoin du jeton de retour : au retour du paiement, il lit la commande avec sa clé secrète.

Exemples en
Node.jsconst { token } = await crenosync('POST', '/booking-sessions');
req.session.guestToken = token;

const choice = req.body;
const quote = await crenosync('POST', '/quotes', {
  guestToken: req.session.guestToken,
  body: {
    activity_id: choice.activity_id,
    date: choice.date,
    time: choice.time,
    participants: [{ category_id: choice.category_id, quantity: Number(choice.quantity) }],
    difficulty_level: choice.difficulty_level ? Number(choice.difficulty_level) : null,
  },
});

const hold = await crenosync('POST', '/holds', {
  guestToken: req.session.guestToken,
  idempotencyKey: crypto.randomUUID(),
  body: { quote_id: quote.id },
});
req.session.quoteId = quote.id;
req.session.holdId = hold.id;
PHP<?php

$_SESSION['guest_token'] = crenosync('POST', '/booking-sessions')['token'];
$guest = ['CrenoSync-Guest-Token: ' . $_SESSION['guest_token']];

$quote = crenosync('POST', '/quotes', [
    'activity_id' => $_POST['activity_id'],
    'date' => $_POST['date'],
    'time' => $_POST['time'],
    'participants' => [['category_id' => $_POST['category_id'], 'quantity' => (int) $_POST['quantity']]],
    'difficulty_level' => ($_POST['difficulty_level'] ?? '') === '' ? null : (int) $_POST['difficulty_level'],
], $guest);

$hold = crenosync('POST', '/holds', ['quote_id' => $quote['id']],
    [...$guest, 'Idempotency-Key: ' . bin2hex(random_bytes(16))]);
$_SESSION['quote_id'] = $quote['id'];
$_SESSION['hold_id'] = $hold['id'];
Pythonimport uuid

session["guest_token"] = crenosync("POST", "/booking-sessions")["token"]

choice = request.form
quote = crenosync("POST", "/quotes", guest_token=session["guest_token"], body={
    "activity_id": choice["activity_id"],
    "date": choice["date"],
    "time": choice["time"],
    "participants": [{"category_id": choice["category_id"], "quantity": int(choice["quantity"])}],
    "difficulty_level": int(choice["difficulty_level"]) if choice.get("difficulty_level") else None,
})

hold = crenosync("POST", "/holds", guest_token=session["guest_token"],
                 idempotency_key=str(uuid.uuid4()), body={"quote_id": quote["id"]})
session["quote_id"] = quote["id"]
session["hold_id"] = hold["id"]
Exemples en
Node.jsconst policies = await crenosync('GET', '/policies');
const body = {
  hold_id: req.session.holdId,
  quote_id: req.session.quoteId,
  customer: { name: 'Camille Tremblay', email: 'camille@example.com', phone: '514 555-0199', language: 'fr' },
  consent: {
    privacy_version_id: policies.privacy.version_id,
    privacy_accepted: true,
    terms_version_id: policies.terms ? policies.terms.version_id : null,
    terms_accepted: policies.terms ? true : null,
  },
  return_url: 'https://www.example.ca/reservation/merci',
  external_reference: 'order-1042',
};
const idempotencyKey = crypto.randomUUID();

let checkout = null;
for (let attempt = 1; !checkout; attempt++) {
  try {
    checkout = await crenosync('POST', '/checkouts', { guestToken: req.session.guestToken, idempotencyKey, body });
  } catch (error) {
    const running = error.code === 'checkout_in_progress' || error.code === 'idempotency_in_progress';
    if (running && attempt < 5) {
      await new Promise((done) => setTimeout(done, Math.min(error.retryAfter || 2, 10) * 1000));
    } else if (error.code === 'quote_changed') {
      req.session.quoteId = error.details.quote.id;
      return res.status(409).json({ new_price: error.details.quote.total });
    } else {
      throw error;
    }
  }
}

req.session.checkoutId = checkout.id;
if (checkout.status === 'confirmed') {
  return res.json({ booked: checkout.booking.reference });
}
if (checkout.next_action?.url) {
  return res.redirect(303, checkout.next_action.url);
}
res.status(202).json({ status: checkout.status });
PHP<?php

$guest = ['CrenoSync-Guest-Token: ' . $_SESSION['guest_token']];
$policies = crenosync('GET', '/policies');
$body = [
    'hold_id' => $_SESSION['hold_id'],
    'quote_id' => $_SESSION['quote_id'],
    'customer' => ['name' => 'Camille Tremblay', 'email' => 'camille@example.com', 'phone' => '514 555-0199', 'language' => 'fr'],
    'consent' => [
        'privacy_version_id' => $policies['privacy']['version_id'],
        'privacy_accepted' => true,
        'terms_version_id' => $policies['terms']['version_id'] ?? null,
        'terms_accepted' => isset($policies['terms']) ? true : null,
    ],
    'return_url' => 'https://www.example.ca/reservation/merci',
    'external_reference' => 'order-1042',
];
$idempotencyKey = 'Idempotency-Key: ' . bin2hex(random_bytes(16));

for ($attempt = 1; ; $attempt++) {
    try {
        $checkout = crenosync('POST', '/checkouts', $body, [...$guest, $idempotencyKey]);
        break;
    } catch (CrenoSyncException $refused) {
        $running = in_array($refused->errorCode, ['checkout_in_progress', 'idempotency_in_progress'], true);
        if ($running && $attempt < 5) {
            sleep(min($refused->retryAfter ?? 2, 10));
            continue;
        }
        if ($refused->errorCode === 'quote_changed') {
            $_SESSION['quote_id'] = $refused->details['quote']['id'];
            http_response_code(409);
            printf('New price: %.2f $. Confirm again to book at this price.', $refused->details['quote']['total']['amount'] / 100);
            exit;
        }
        throw $refused;
    }
}

$_SESSION['checkout_id'] = $checkout['id'];
if ($checkout['status'] === 'confirmed') {
    echo 'Booked: ', htmlspecialchars($checkout['booking']['reference']);
} elseif (isset($checkout['next_action']['url'])) {
    header('Location: ' . $checkout['next_action']['url'], true, 303);
} else {
    http_response_code(202);
    echo 'Not confirmed yet: ', htmlspecialchars($checkout['status']);
}
Pythonimport time
import uuid

policies = crenosync("GET", "/policies")
terms = policies.get("terms")
body = {
    "hold_id": session["hold_id"],
    "quote_id": session["quote_id"],
    "customer": {"name": "Camille Tremblay", "email": "camille@example.com", "phone": "514 555-0199", "language": "fr"},
    "consent": {
        "privacy_version_id": policies["privacy"]["version_id"],
        "privacy_accepted": True,
        "terms_version_id": terms["version_id"] if terms else None,
        "terms_accepted": True if terms else None,
    },
    "return_url": "https://www.example.ca/reservation/merci",
    "external_reference": "order-1042",
}
idempotency_key = str(uuid.uuid4())

checkout = None
attempt = 0
while checkout is None:
    attempt += 1
    try:
        checkout = crenosync("POST", "/checkouts", guest_token=session["guest_token"],
                             idempotency_key=idempotency_key, body=body)
    except CrenoSyncError as error:
        if error.code in ("checkout_in_progress", "idempotency_in_progress") and attempt < 5:
            time.sleep(min(error.retry_after or 2, 10))
        elif error.code == "quote_changed":
            session["quote_id"] = error.details["quote"]["id"]
            return {"new_price": error.details["quote"]["total"]}, 409
        else:
            raise

session["checkout_id"] = checkout["id"]
if checkout["status"] == "confirmed":
    return {"booked": checkout["booking"]["reference"]}
next_action = checkout.get("next_action") or {}
if "url" in next_action:
    return redirect(next_action["url"], code=303)
return {"status": checkout["status"]}, 202
Exemples en
Node.jsconst path = `/checkouts/${req.session.checkoutId}`;
let checkout = await crenosync('GET', path);
for (let wait = 2; checkout.status === 'processing' && wait <= 8; wait *= 2) {
  await new Promise((done) => setTimeout(done, wait * 1000));
  checkout = await crenosync('GET', path);
}

if (checkout.status === 'confirmed') {
  const booking = await crenosync('GET', `/bookings/${checkout.booking.reference}`);
  return res.json({ reference: booking.reference, status: booking.status });
}
const messages = {
  processing: 'Your payment is being confirmed. You will receive an email; don\'t pay again.',
  attention_required: 'Your payment was received. The business will finish your booking and write to you; don\'t pay again.',
  expired: 'The booking was not made. You can start again.',
  cancelled: 'The booking was not made. You can start again.',
};
res.json({ status: checkout.status, message: messages[checkout.status] || 'Your booking is not confirmed yet.' });
PHP<?php

$path = '/checkouts/' . rawurlencode($_SESSION['checkout_id']);
$checkout = crenosync('GET', $path);
for ($wait = 2; $checkout['status'] === 'processing' && $wait <= 8; $wait *= 2) {
    sleep($wait);
    $checkout = crenosync('GET', $path);
}

if ($checkout['status'] === 'confirmed') {
    $booking = crenosync('GET', '/bookings/' . rawurlencode($checkout['booking']['reference']));
    echo 'Booked: ', htmlspecialchars($booking['reference']), ', status ', htmlspecialchars($booking['status']);
} else {
    echo match ($checkout['status']) {
        'processing' => 'Your payment is being confirmed. You will receive an email; don\'t pay again.',
        'attention_required' => 'Your payment was received. The business will finish your booking and write to you; don\'t pay again.',
        'expired', 'cancelled' => 'The booking was not made. You can start again.',
        default => 'Your booking is not confirmed yet.',
    };
}
Pythonimport time

path = f"/checkouts/{session['checkout_id']}"
checkout = crenosync("GET", path)
wait = 2
while checkout["status"] == "processing" and wait <= 8:
    time.sleep(wait)
    checkout = crenosync("GET", path)
    wait *= 2

if checkout["status"] == "confirmed":
    booking = crenosync("GET", f"/bookings/{checkout['booking']['reference']}")
    return {"reference": booking["reference"], "status": booking["status"]}

messages = {
    "processing": "Your payment is being confirmed. You will receive an email; don't pay again.",
    "attention_required": "Your payment was received. The business will finish your booking and write to you; don't pay again.",
    "expired": "The booking was not made. You can start again.",
    "cancelled": "The booking was not made. You can start again.",
}
return {"status": checkout["status"], "message": messages.get(checkout["status"], "Your booking is not confirmed yet.")}
curlANSWER=
: "${CHECKOUT_ID:?no checkout: run the checkout step first}" \
  && ANSWER=$(curl -sS --fail-with-body "$CRENOSYNC_API/checkouts/$CHECKOUT_ID" \
    -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json') \
  && STATUS=$(jq -r '.status' <<<"$ANSWER") \
  && echo "Checkout: $STATUS" \
  && if [ "$STATUS" = confirmed ]; then
    ANSWER=$(curl -sS --fail-with-body "$CRENOSYNC_API/bookings/$(jq -r '.booking.reference' <<<"$ANSWER")" \
      -H "Authorization: Bearer $CRENOSYNC_SECRET_KEY" -H 'Accept: application/json') \
      && echo "$ANSWER"
  fi \
  || echo "$ANSWER"
  • N’envoyez jamais la clé secrète, un jeton d’invité ou next_action.url au navigateur d’un autre visiteur.
  • La clé secrète lit seulement les réservations faites avec elle ; il n’y a pas de recherche par nom, courriel ou téléphone.
  • external_reference (votre numéro de commande) et attribution (source, medium, campaign, comme dans les balises UTM) reviennent avec la réservation quand vous la lisez. N’y mettez jamais de renseignements personnels.

09Le site d’exemple

Une page de réservation complète en HTML et JavaScript simples, sans cadriciel ni étape de compilation : une activité, une date, un groupe, les heures libres, le prix, la retenue avec son compte à rebours, les coordonnées et les politiques, puis le code ou le paiement, et la page de retour. Téléchargez ses fichiers, mettez votre adresse d’API et votre clé publiable dans config.js, et servez le dossier depuis un site Web inscrit avec la clé.

Pour un essai local, le propriétaire inscrit http://localhost:8000 comme site Web et http://localhost:8000/merci.html comme adresse de retour sur une clé d’essai ; lancez ensuite php -S localhost:8000 dans le dossier.

10Pas dans la version 1

Les webhooks, l’annulation ou le déplacement d’une réservation, les remboursements, plusieurs réservations dans une commande, plusieurs emplacements et le paiement perçu par votre propre fournisseur sont prévus après la version 1. D’ici là, l’entreprise traite les annulations et les remboursements dans son portail.

Une question, un problème, une idée ? Écrivez-nous, avec le Request-Id de la réponse en cause s’il y en a un.