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_allowedotherwise). A server may use it from anywhere. - A request with a secret key and a browser
Originheader 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/jsonwith a body, andAccept: application/json. - Language.
Accept-Language: froren; 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 inGET /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 itsreference, 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_actionmeans "send the visitor to itsurl". Breaking changes would go into a new version.
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.sessionandresas in Express. - Python with
requests: the examples run in a view, withsessionandrequestas in Flask (Django'srequest.sessionworks 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.
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}.
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 tonext_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.
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.urlin the whole window, never in a frame or a pop-up. It is a signed link for this checkout only, valid untilnext_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_atgives 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:
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.');
}
processingmeans 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_requiredmeans 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(httponly for a local development host). SendReferrer-Policy: no-referreron 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.
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-codesends 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.
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.
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"]
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
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.urlto 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) andattribution(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.
-
index.htmlThe booking page -
app.jsThe booking journey -
merci.htmlThe return page -
config.jsYour API address and key -
style.cssThe page's look -
README.mdHow to set it up
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.