For developers
Joyal Booking API
Connect your own booking system to Joyal, so customers who book through their Joyal app get their loyalty points automatically.
Overview
How it works
Your booking page keeps a code that Joyal adds to the link. Your server then tells Joyal when that booking is paid, moved or cancelled, and Joyal adds the customer's points after the session.
| Step | Who | What happens |
|---|---|---|
| 1 | Customer | Taps Book in their Joyal app. |
| 2 | Joyal | Opens your booking page with a joyal_ref on the link. |
| 3 | Your booking page | Keeps the joyal_ref with the booking while the customer books and pays. |
| 4 | Your server | Tells Joyal when that booking is paid, moved or cancelled, with your API key. |
| 5 | Joyal | Shows the customer their points as on the way, then adds them after the session. |
https://joyal.co.For your AI assistant
Using Claude Code, Codex or another AI assistant?
One prompt carries this whole guide. Paste it into your assistant: it finds where your bookings are made, paid for, moved and cancelled, then plans the changes with you before it touches anything.
Show the prompt Hide the prompt
I want to connect our booking system to the Joyal Booking API, so customers who book through their Joyal app get loyalty points automatically. Before changing any code, find where in this codebase bookings are created, paid for, moved and cancelled, and where the booking page reads its link. Then write me a plan, and wait for my go-ahead before you change anything.
THE SPEC
1. The booking link
Joyal opens our booking page with ?joyal_ref=JY-XXXXXXXXXX on the link. It adds it only to https links on our booking site's own address, the one registered with our key, so the business's booking link in Joyal must be that address. Keep it only if it matches ^JY-[A-HJKMNP-Z2-9]{10}$ (on a test copy of the site, also accept JY-TEST-PAID, JY-TEST-EXPIRED and JY-TEST-OTHER). Store it as text on the booking, in a nullable joyal_ref column, and carry it through payment (for example in Stripe Checkout metadata). It is a label only: it must never change a price or anything else.
2. The messages
Only for bookings that carry a joyal_ref, send one message when the booking is:
- paid: booking.created, once the payment has gone through, never when the form is submitted
- moved: booking.rescheduled, with the new times
- cancelled, or refunded in full: booking.cancelled
Never send the purchase of a pass, package, gift voucher or membership. One time slot is one booking: split a checkout that covers several slots into one booking each, with its own id and its share of the amount.
POST https://joyal.co/api/v1/booking-events
(until launch, the test address Joyal sent us)
Authorization: Bearer <JOYAL_BOOKING_KEY>
Content-Type: application/json
{
"id": "<unique to this message, 1 to 100 characters, the same on every retry>",
"type": "booking.created" | "booking.rescheduled" | "booking.cancelled",
"occurred_at": "<when the change happened, ISO 8601 with a time zone>",
"booking": {
"id": "<our booking id, 1 to 100 characters, never changes or reused>",
"joyal_ref": "<exactly as it arrived on the link>",
"starts_at": "<ISO 8601 with a time zone, at most a year ahead>",
"ends_at": "<1 minute to 24 hours after starts_at>",
"amount_paid": <whole cents actually taken, after discounts, passes and vouchers, 0 to 10000000>,
"currency": "EUR"
}
}
The key (jy_test_... or jy_live_...) lives on the server only, in an environment variable, never in the booking page, an app or a link. Every message carries the whole booking as it stands after the change. Joyal ignores fields it does not know. Paying later is not supported: send booking.created only once the customer has paid at booking.
3. Sending reliably (the transactional outbox pattern)
Write each message to an outbox table in the same database transaction as the booking change. A worker sends them: one booking's messages one at a time, in the order they happened, keeping the same message id on every retry.
- 2xx: done, mark it sent.
- 400: the message is wrong; the reply's error.field says which. Mark it failed and alert a person. A corrected message may be sent under the same id.
- 422 (id_reused): the id was already used for a different message. Mark it failed and alert a person.
- 401 (key), 429, 5xx, or no reply within 10 seconds: keep it and retry after 1 minute, 10 minutes and 1 hour, then every 6 hours, for up to 3 days.
4. Replies
200: {"received": true, "mode": "test" | "live", "outcome", "points"?, "credit_after"?, "detail"}
outcome is one of: matched, held, unmatched, switched_off, moved, cancelled, already_cancelled, already_credited, not_held.
Errors: {"error": {"code", "message", "field"?}}
5. Testing
A test key runs every check and replies with what would happen, recording no booking. Test with joyal_ref JY-TEST-PAID (matched), JY-TEST-EXPIRED (unmatched: the code had expired) and JY-TEST-OTHER (unmatched: another business's code), for a create, a move and a cancel. A live key refuses these three codes.
Questions: info@joyal.coStep one
Add a joyal_ref column to your bookings
One text column, empty for every booking that did not come from the Joyal app.
-- PostgreSQL. Any database works: it is one optional text column. alter table bookings add column joyal_ref text;
joyal_ref column that is empty on every booking so far.Step two
Catch it on the booking page
When a customer taps Book, Joyal opens your booking page with one extra value on the link:
https://book.example.ie/sauna?joyal_ref=JY-AB23CD45XY
// On your booking page. Keep the code only if it is a real one.
const JOYAL_REF = /^JY-[A-HJKMNP-Z2-9]{10}$/
const ref = new URLSearchParams(window.location.search).get('joyal_ref')
if (ref && JOYAL_REF.test(ref)) {
// Send it with the booking form, as a hidden field, so your server
// stores it on the booking it creates.
document.querySelector('input[name="joyal_ref"]').value = ref
}- The format is always the same:
JY-and 10 capital letters and numbers, 13 characters in all. I, L, O, 0 and 1 are never used. A value that does not match is not a Joyal code: leave the column empty. - Joyal adds it only to https links on your booking site's own address, the one registered with your key (for example
book.example.ie). Make sure the business's booking link in Joyal startshttps://and is on that address: any other link opens without a code. - Store it exactly as it came, as text. It is different every time, and it is not a key for the business.
- Treat it as a label, nothing more. It comes from a link the customer can see, so never let it change a price or anything else in your system.
- On a test copy of your site only, also accept
JY-TEST-PAID,JY-TEST-EXPIREDandJY-TEST-OTHER(step 7). A live key refuses them.
?joyal_ref=JY-AB23CD45XY on the link and making a booking leaves that code on the booking.Step three
Keep it through payment
Customers go off to pay and come back. That is where a code most often gets lost, so carry it with the payment too.
// Stripe Checkout, for example: metadata comes back on the
// checkout.session.completed event, so the code survives the trip.
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
metadata: { booking_id: booking.id, joyal_ref: booking.joyal_ref ?? '' },
success_url: 'https://book.example.ie/booked',
})joyal_ref.Step four
Tell Joyal when a booking is paid
One function sends one message. Call it once the payment has gone through (in Stripe's case, when checkout.session.completed arrives), never when the form is submitted.
// Node.js 18 or later. The key lives on your server only.
const JOYAL_URL = 'https://joyal.co/api/v1/booking-events'
function bookingMessage(type, booking) {
return {
id: crypto.randomUUID(), // new for each message, kept on retries
type, // 'booking.created', 'booking.rescheduled' or 'booking.cancelled'
occurred_at: new Date().toISOString(),
booking: {
id: String(booking.id), // your id; it never changes
joyal_ref: booking.joyal_ref,
starts_at: booking.starts_at.toISOString(),
ends_at: booking.ends_at.toISOString(),
amount_paid: booking.amount_paid_cents, // 2500 is €25.00
currency: 'EUR',
},
}
}
async function sendToJoyal(message) {
const res = await fetch(JOYAL_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.JOYAL_BOOKING_KEY}`,
},
body: JSON.stringify(message),
signal: AbortSignal.timeout(10000),
})
return { status: res.status, reply: await res.json().catch(() => null) }
}
// Where the payment is confirmed (Stripe: checkout.session.completed).
// queueForJoyal keeps it until it is sent (step 6).
if (booking.joyal_ref) {
await queueForJoyal(bookingMessage('booking.created', booking), tx)
}# Python 3, with the requests library
import os, requests
JOYAL_URL = "https://joyal.co/api/v1/booking-events"
def send_to_joyal(message):
res = requests.post(
JOYAL_URL,
json=message,
headers={"Authorization": f"Bearer {os.environ['JOYAL_BOOKING_KEY']}"},
timeout=10,
)
return res.status_code, res.json() if res.content else None- Only bookings that carry a joyal_ref. We do not want data on people who did not come through Joyal, and a message without one records nothing.
- Bookings only. Never send the purchase of a pass, package, gift voucher or membership. A session booked with one of them is a booking: send it, with the money actually taken for it, usually 0.
- One time slot per booking. If one checkout covers several slots, send each slot as its own booking, with its own id and its share of the amount. Several people in one slot is one booking.
Step five
Tell Joyal about moves and cancellations
The same function, with the booking as it stands after the change. A move carries the new times.
// When the customer moves the booking to a new slot:
if (booking.joyal_ref) {
await queueForJoyal(bookingMessage('booking.rescheduled', booking), tx)
}
// When it is cancelled, or refunded in full:
if (booking.joyal_ref) {
await queueForJoyal(bookingMessage('booking.cancelled', booking), tx)
}| Type | Send it when |
|---|---|
booking.created | The booking is confirmed and paid. |
booking.rescheduled | Its date or time changes. |
booking.cancelled | It is cancelled, or refunded in full. |
Step six
Retry when Joyal does not answer
Keep each message in a small "waiting to send" table, written in the same transaction as the booking change, and send from there. If Joyal is briefly down, nothing is lost. This is the transactional outbox pattern.
create table joyal_outbox ( id bigserial primary key, booking_id text not null, message jsonb not null, -- exactly what bookingMessage() made attempts int not null default 0, next_try_at timestamptz not null default now(), sent_at timestamptz, failed_at timestamptz -- a 400 or 422: needs a person );
// Called inside the same transaction that changes the booking, so the
// message is kept if, and only if, the change is.
async function queueForJoyal(message, tx) {
await tx.query(
'insert into joyal_outbox (booking_id, message) values ($1, $2)',
[message.booking.id, message])
}// Run every minute. One booking's messages go one at a time, in order.
async function sendWaitingMessages() {
const due = await db.query(`
select distinct on (booking_id) * from joyal_outbox
where sent_at is null and failed_at is null
order by booking_id, id`)
for (const row of due.rows) {
if (new Date(row.next_try_at) > new Date()) continue // the oldest waits, so later ones wait too
const { status, reply } = await sendToJoyal(row.message).catch(() => ({ status: 0 }))
if (status >= 200 && status < 300) {
await db.query('update joyal_outbox set sent_at = now() where id = $1', [row.id])
} else if (status === 400 || status === 422) {
await db.query('update joyal_outbox set failed_at = now() where id = $1', [row.id])
console.error('Joyal refused a message', row.id, reply) // fix it, then send it again
} else {
// 401, 429, 5xx or no answer: keep it and try later.
const gaps = [1, 10, 60, 360] // minutes, then every 6 hours
const wait = gaps[Math.min(row.attempts, gaps.length - 1)]
await db.query(
`update joyal_outbox set attempts = attempts + 1,
next_try_at = now() + make_interval(mins => $2) where id = $1`, [row.id, wait])
}
}
}- In order, one at a time per booking. A move sent before its booking, or two moves the wrong way round, can leave the points at the wrong time.
- Keep the same message id on every retry. Joyal answers a repeat with the first reply and does nothing twice.
- Keep a 401 in the outbox. It means the key needs fixing, and once it is fixed the waiting messages go through, cancellations included.
- Give up after 3 days of trying, and tell a person.
Step seven
Test with your test key
A test key (jy_test_...) runs every check a live key does, then replies with what would happen. It never records a booking or touches a customer, so you can send it anything.
| Test code | Joyal replies as if |
|---|---|
JY-TEST-PAID | A customer tapped Book and paid: matched, with the points it would add. |
JY-TEST-EXPIRED | The customer's code had run out: unmatched. |
JY-TEST-OTHER | The code came from a different business: unmatched. |
curl https://joyal.co/api/v1/booking-events \
-H "Authorization: Bearer $JOYAL_BOOKING_KEY" \
-H "Content-Type: application/json" \
-d '{
"id": "evt_test_1",
"type": "booking.created",
"occurred_at": "2026-10-22T11:03:00Z",
"booking": {
"id": "SW-48213",
"joyal_ref": "JY-TEST-PAID",
"starts_at": "2026-11-14T10:00:00Z",
"ends_at": "2026-11-14T11:00:00Z",
"amount_paid": 2500,
"currency": "EUR"
}
}'{
"received": true,
"mode": "test",
"outcome": "matched",
"points": 25,
"credit_after": "2026-11-14T11:00:00.000Z",
"detail": "Would add 25 points after 2026-11-14T11:00:00.000Z."
}The points depend on the business's own rate: this one gives 1 point per euro. If the business has not switched points for bookings on yet, the outcome is switched_off, and the detail still says what would happen once it does. Send the same message again and you get the same outcome, marked as already received. Change something under the same id and you get a 422.
Step eight
Go live
Joyal gives you a live key for each business you connect. Then one real booking confirms it.
- Swap in the live key (
jy_live_...), on your server only. Never in the booking page, an app or a link. - Accept real codes only on your live booking page: the test codes are refused with a live key.
- Check the business's booking link in Joyal starts
https://and is on your booking site's address, so the code arrives on it. - Send only bookings that carry a joyal_ref, once paid.
- Check your outbox sends each booking's messages in order, and alerts a person on a 400 or 422.
- Book one real session from the business's Joyal app, and check the customer sees their points on the way.
matched reply, and the customer sees its points as on the way.Reference
The message
A POST to https://joyal.co/api/v1/booking-events with a JSON body, and your key in the Authorization header. Every message carries the whole booking as it stands after the change.
POST /api/v1/booking-events
Authorization: Bearer jy_live_...
Content-Type: application/json
{
"id": "evt_7Q2M9K4X",
"type": "booking.created",
"occurred_at": "2026-10-22T11:03:00Z",
"booking": {
"id": "SW-48213",
"joyal_ref": "JY-AB23CD45XY",
"starts_at": "2026-10-25T10:00:00Z",
"ends_at": "2026-10-25T11:00:00Z",
"amount_paid": 2500,
"currency": "EUR"
}
}| Field | What it holds |
|---|---|
id | Text, 1 to 100 characters. Unique to this message, and the same on every retry of it. |
type | booking.created, booking.rescheduled or booking.cancelled. |
occurred_at | A time. When the change happened in your system, not when you sent it. |
booking.id | Text, 1 to 100 characters. Your id for the booking. It never changes and is never reused. |
booking.joyal_ref | Text, exactly as it arrived on the link. |
booking.starts_at | A time. When the session starts. No more than a year ahead. |
booking.ends_at | A time. When the session ends: at least 1 minute and at most 24 hours after it starts. Points are added after this. |
booking.amount_paid | A whole number of cents, 0 to 10,000,000. The money actually taken for this booking, after any discount, pass or voucher. 2500 is €25.00. |
booking.currency | EUR. Joyal only handles euros for now. |
- Times are ISO 8601 with a time zone:
2026-10-25T10:00:00Zor2026-10-25T11:00:00+01:00. A time with no zone is refused, because it could mean any hour. - Fields Joyal does not know are ignored, not refused, so you can add your own.
- Payment at booking only. Send
booking.createdonce the customer has paid. Paying later, at the door, is not supported yet: the code runs out 24 hours after the customer taps Book.
Reference
Joyal's replies, and when to send again
Joyal replies within 10 seconds. Anything that is not a 2xx comes with a code and a plain message: {"error": {"code", "message", "field"}}.
| Reply | It means | What to do |
|---|---|---|
| 200 | Received. The reply's outcome says what Joyal did, even when the booking earns nothing. | Done. Mark it sent. |
| 400 | Something in the message is wrong. field names it. The message id is not used up. | Fix the cause, then send the corrected message under the same id. |
| 401 | The key is missing or not recognised. | Keep the message. Fix the key, then send it again. |
| 422 | id_reused: this id was already used for a different message. | Give every message its own id. Keep one id only for resending the same message. |
| 429 | Too many messages this minute. | Keep it and send it again in a minute. |
| 500, or no reply in 10 seconds | A problem on Joyal's side. | Keep it and try again later (step 6). |
| Outcome | What Joyal did |
|---|---|
matched | Recorded for the customer. points are on the way, added after credit_after. |
held | Recorded, but waiting for the business to check it before any points are added. |
unmatched | The code was not usable (unknown, expired or another business's), so no points. detail says which. |
switched_off | Points for bookings are off at this business, so nothing was recorded. |
moved | The booking's times were changed. |
cancelled | The booking was cancelled, and no points will be added for it. |
already_cancelled | Joyal already holds it as cancelled. Nothing changed. |
already_credited | Its points were already added. Nothing changed. |
not_held | Joyal holds no booking with that id. Nothing changed. |
On our side
What Joyal does with each message
You send the facts about the booking, and Joyal works out the points, so your system never needs to know anything about them.
- Points come from the amount paid, at the rate the business sets in Joyal, rounded down. At 1 point per €1, a €25.00 booking earns 25 points.
- The customer sees them straight away as points on the way, and they are added within a day of the session ending.
- A cancellation before then means no points. One after the points are added does not take them back; the business can adjust a balance by hand.
- A move changes when the points arrive, never how many.
- A booking paid with a pass, a voucher or nothing earns nothing automatically. The business can add points for it by hand.
- A single booking over €1,000, or more than 10 paid bookings from one tap of Book, waits for the business to check it before any points are added.
- A code earns only at the business it was made for, and only for a booking made within 24 hours of the customer tapping Book. The session itself can be on any date.
- A cancellation for a booking Joyal has not seen is kept only if it carries this business's code, so a
booking.createdthat arrives late earns nothing. - Nothing about the customer is sent or stored beyond the code: no name, no email.