CrenoSync

Developers · Version 1.0.0

Booking API

Show a business's activities, prices and open times on its own website, and book them, while CrenoSync stays the source of truth for the schedule, the bookings and the payments.

← Back to home

In short

  • Your site, live dataActivities, prices and open times read from CrenoSync at the moment the visitor looks.
  • Two keysA publishable key for web pages, a secret key for servers. Made by the owner.
  • Payment by CrenoSyncCards are typed on CrenoSync's secure page, never on your site.
  • French and EnglishTexts, messages and emails in the visitor's language.

01Before you start

The API is for a business on CrenoSync's Pro plan that wants its own website to show and book its activities. Each business has its own address, and every key belongs to one business only:

  • https://{business}.crenosync.ca/api/v1, where {business} is the business's CrenoSync subdomain (the one of its hosted site).
  • The business's owner makes the keys in the staff portal, under Settings › API, and registers there the websites that may use them and the addresses visitors come back to after paying. Ask them for the keys; CrenoSync never sends a key itself.

Testing

Don't try your integration on a business's real schedule: a test booking would take a real time. Write to us for a test business and a key on CrenoSync's staging environment, https://{business}.staging.crenosync.ca/api/v1. Its payments use the providers' test modes and their test cards, and its emails go to a test inbox.

What you get here

  • This guide: how the pieces fit, what to do at each step, and the mistakes to avoid.
  • The reference: every endpoint, field and error code, generated from the contract.
  • The OpenAPI file (OpenAPI 3.1), for your tools and code generators.
  • A sample website in plain HTML and JavaScript that books from start to finish.

02Keys and tokens

Every request carries a key in Authorization: Bearer …. The booking steps add a token for the visitor.

Credential Where it lives What it does
Publishable key cs_pk_ In your web pages, on the websites registered with it Reads the catalog and availability, opens booking sessions
Secret key cs_sk_ On your server only Everything the publishable key does, and reads the bookings made with this key
Guest token cs_gt_ One visitor's booking attempt, 60 minutes (sessionStorage, or your server's session) That visitor's quotes, holds, checkouts and booking
Return token cs_rt_ After # on your return page, 30 minutes Reads one checkout and its booking
  • A browser may use the publishable key only on a website registered with it in Settings › API (403 origin_not_allowed otherwise). A server may use it from anywhere.
  • A request with a secret key and a browser Origin header is refused (403 secret_key_in_browser). That only guards against a mistake: a secret key copied from a page, a repository, a log or a support message still works from a server. If one was ever exposed, the owner revokes it and makes a new one at once.
  • The secret key is shown once, when the owner makes it. Keep it in your server's configuration (an environment variable), never in code you commit.

03Conventions

  • JSON over HTTPS. Send Content-Type: application/json with a body, and Accept: application/json.
  • Language. Accept-Language: fr or en; by default the business's language. Texts, messages and emails follow it; codes never change.
  • Money in cents with its currency: {"amount": 4250, "currency": "CAD"} is 42.50 $. Show the amounts CrenoSync gives you; never compute a price yourself.
  • Time. Every business runs on Eastern time (America/Toronto, named in GET /site). Dates (2026-11-14) and times (19:30) are local; full timestamps carry their offset (2026-11-14T19:30:00-05:00).
  • IDs are opaque strings with a prefix (act_, cat_, quo_, hld_, chk_). Store them as they are; never parse them. A booking is known by its reference, the booking number its customer receives.
  • Request-Id. Every answer carries one. Log it with the error, and quote it when you write to us.
  • New values. Fields and error codes may be added to v1 at any time: ignore what you don't know. Some fields are open lists (marked so in the reference) and may gain values: an unknown status means "not confirmed yet", an unknown next_action means "send the visitor to its url". Breaking changes would go into a new version.
Examples in
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'

The examples on this page use the small helpers above: api() in a web page with the publishable key, and crenosync() on a server with the secret key, read from the CRENOSYNC_SECRET_KEY environment variable. Each one throws an error carrying the API's code, its details, the Retry-After delay and the Request-Id (in PHP a CrenoSyncException, its code in errorCode), so an example stops at a refusal unless it handles it.

  • Node.js 20 or later, with no package: the examples run in a route of your framework, with req.session and res as in Express.
  • Python with requests: the examples run in a view, with session and request as in Flask (Django's request.session works the same).
  • curl 7.76 or later to try the API from a terminal: the commands keep the IDs in shell variables with jq. With --fail-with-body, a refusal is printed and leaves the step's variables empty: stop there and fix it before the next step.

The examples take the business's first activity, its first price category, the smallest group and the first open time in the next 7 days. On your page the visitor chooses them: the server examples read that choice from the form (activity_id, date, time, category_id, quantity, difficulty_level), and the quote, hold and checkout IDs travel in the visitor's session from one request to the next.

04The booking journey

A booking goes through six steps. Availability is a snapshot: a time shown as open can be taken by someone else until it is held, and only a confirmed checkout is a booking.

1. Show the activities and their open times

GET /activities lists what can be booked, with its price categories (adult, child…), the group sizes and any difficulty levels. GET /activities/{id}/availability gives the open times for a group size over at most 31 days. Fetch it when the visitor opens the calendar or changes the date or group, not on a timer.

Examples in
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"

In the reference, with their full request and answer: List activities, Open times.

2. Start a booking session

When the visitor starts booking (not on page load), POST /booking-sessions gives a guest token for that visitor alone. Send it as CrenoSync-Guest-Token with every following step. It lasts 60 minutes and allows a few holds at once (limits.holds).

In the reference, with their full request and answer: Start a booking session.

3. Get the price

POST /quotes checks the group against the activity's rules and gives the exact price: each line, the discount of a promotion code, the taxes, the total, what is due now (due_now: the deposit, or the full price when the activity asks for it, as payment says) and at the venue. Show it as it is. A quote is valid 15 minutes and reserves nothing.

In the reference, with their full request and answer: Price a booking.

4. Hold the time

POST /holds with the quote takes the time (or the group's places in a shared session) while the visitor fills in their details: 10 minutes by default, not renewed. Send an Idempotency-Key (a UUID) so a retry never holds twice. If the visitor goes back or chooses another time, release the hold with DELETE /holds/{id}.

Examples in
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"

In the reference, with their full request and answer: Hold a time.

5. Check out

Show the privacy policy and the terms from GET /policies next to the acceptance boxes, then send POST /checkouts with the hold, the quote the visitor saw, their name, email and phone, the policy versions they accepted and your return_url. The answer's status says what comes next:

  • confirmed: booked. Nothing to pay online, no code to type.
  • awaiting_verification: the business confirms such bookings with a 6-digit code sent by email (see below).
  • awaiting_payment: send the visitor to next_action.url, CrenoSync's payment page (see below).

If the price changed since the quote, the answer is 409 quote_changed with the new quote: show it, and send the checkout again with the new quote's ID, the same hold and a new Idempotency-Key. While a first request for the same hold is still running (a double click, a second tab), the answer is 409 checkout_in_progress: wait for Retry-After and send it again; the checkout already open comes back.

Examples in
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"

In the reference, with their full request and answer: The policies in force, Check out, Confirm with the email code.

6. Show the booking

GET /bookings/{reference} gives the booking: activity, date, time, group, price, what was paid and what is left to pay at the venue. The customer also gets CrenoSync's confirmation email, in their language.

In the reference, with their full request and answer: Get a booking.

05Payment and the return

When something is due online, the visitor pays on CrenoSync's page, with the business's own payment provider. Card numbers never touch your site, and you have nothing to do with the provider.

  • Send the visitor to next_action.url in the whole window, never in a frame or a pop-up. It is a signed link for this checkout only, valid until next_action.expires_at: don't store it, log it or send it anywhere else. The first browser that opens it keeps the checkout; another browser is refused.
  • The payment window lasts 30 minutes, cut short to end 15 minutes before the activity starts, but never less than 10 minutes. The time stays held 5 minutes more for the payment to be confirmed. A payment made within the window is booked, even if the activity has just started. The checkout's expires_at gives the window's end.
  • Some providers' forms can expire before the window ends. CrenoSync's page then offers the visitor a new form for the same checkout; you have nothing to do.

Once paid, the visitor comes back to your return_url with the checkout's ID and a return token after #: https://www.example.ca/reservation/merci#checkout_id=chk_…&return_token=cs_rt_…. Browsers never send that part to your server, its logs or other sites. Read it in the page's script, take it out of the address with history.replaceState before any analytics script runs, and read the checkout with it:

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 means the payment is being confirmed with the provider: read it again after 2, 4, 8 seconds… for a minute or two. If it is still processing, tell the visitor an email will follow. Never ask them to pay again.
  • attention_required means the visitor paid but the booking couldn't be made automatically (the time was taken meanwhile, for instance). The business was alerted and will contact them. Never ask them to pay again either.
  • The return address must be one of those registered with the key, in https (http only for a local development host). Send Referrer-Policy: no-referrer on that page.
  • A server integration doesn't need the return token: it reads the checkout it opened with its secret key (see below).

A checkout stays confirmed once booked. If the business later cancels the booking in its portal, the checkout doesn't change: read the booking's own status (confirmed or cancelled) to know where it stands.

06The email code

A business may confirm the bookings that have nothing to pay online with a 6-digit code sent to the visitor's email. The checkout then answers awaiting_verification, with next_action.email_hint (the address, partly hidden) and attempts_left. Either ask for the code on your own page, or send the visitor to next_action.url to type it on CrenoSync's page, which ends on your return_url like a payment.

Examples in
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"}'
  • Five wrong codes cancel the checkout and release the time (409 verification_locked).
  • POST /checkouts/{id}/resend-code sends a new code: three per checkout at most, one a minute.
  • The code works until the hold ends; after that the checkout is expired.

07Errors and retries

Every error has the same shape, with a stable code for your program and a message for people, in the request's language. Test the code, never the message.

Answer What to do
401, 403 A key, token or website problem (invalid_key, origin_not_allowed, guest_token_expired…). Fix the configuration, or start a new booking session.
404 not_found Nothing with that ID for this key or token. Records of another visitor, key or business answer the same.
409 The situation changed: time_unavailable (pick another time), hold_expired (start again from the availability), quote_changed and policy_changed (show the new price or texts, then send again). checkout_in_progress and idempotency_in_progress: wait for Retry-After and send the same request again.
422 The request breaks a rule: validation_failed with each field's messages in fields, or a booking rule (group_invalid, promo_code_invalid, return_url_not_allowed…). Show the message next to the field.
429 Too many requests, holds or codes. Wait for Retry-After (seconds) before trying again.
500, 503 A failure on CrenoSync's side, or the business is unavailable for a moment. Try again later; quote the Request-Id.

Idempotency

POST /holds and POST /checkouts need an Idempotency-Key header, a UUID you make for each operation. Sending the same key and body again within 24 hours gives back the first answer (Idempotent-Replayed: true) instead of a second hold or checkout. So after a timeout or a network error, retry with the same key; after changing the body (a new quote), use a new one.

Answers that aren't from CrenoSync

A 502, 503 or 504 can come from the hosting's web server in front of CrenoSync, as an HTML page without Request-Id. Treat it like any temporary failure: wait a few seconds and send the same request again, with the same Idempotency-Key. A 429 from the address limit also refuses browser preflights, which the browser reports as a network error.

Examples in
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\"}"

08From your server

A website that books from its own server uses the secret key and the same steps. It makes one booking session per visitor (the limits on holds count per guest token), keeps the guest token in that visitor's session, and needs no return token: back from the payment, it reads the checkout with its secret key.

Examples in
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"]
Examples in
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
Examples in
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"
  • Never send the secret key, a guest token or next_action.url to another visitor's browser.
  • The secret key reads only the bookings made with it; there is no search by name, email or phone.
  • external_reference (your order number) and attribution (source, medium, campaign, as in UTM tags) come back with the booking when you read it. Never put personal information in them.

09The sample website

A complete booking page in plain HTML and JavaScript, with no framework and no build step: an activity, a date, a group, the open times, the price, the hold with its countdown, the details and the policies, then the code or the payment, and the return page. Download its files, put your API address and publishable key in config.js, and serve the folder from a website registered with the key.

For a local try, the owner registers http://localhost:8000 as a website and http://localhost:8000/merci.html as a return address on a test key; then run php -S localhost:8000 in the folder.

10Not in version 1

Webhooks, cancelling or moving a booking, refunds, several bookings in one checkout, several locations and payment collected by your own provider are planned after version 1. Until then, the business handles cancellations and refunds in its portal.

A question, a problem, an idea? Write to us, with the Request-Id of the answer concerned if there is one.