Joyal

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.

One link value to keep One message to send JSON over HTTPS

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.

StepWhoWhat happens
1CustomerTaps Book in their Joyal app.
2JoyalOpens your booking page with a joyal_ref on the link.
3Your booking pageKeeps the joyal_ref with the booking while the customer books and pays.
4Your serverTells Joyal when that booking is paid, moved or cancelled, with your API key.
5JoyalShows the customer their points as on the way, then adds them after the session.
You can start today. A test key checks everything and replies with what would happen, without touching any real customer. Three reserved test codes give you every outcome before a single real booking (step 7). Until launch, use the test address Joyal sends you in place of https://joyal.co.
Any language works. The examples below are in JavaScript (Node.js), Python and PostgreSQL, but all Joyal needs is one HTTPS request with a JSON body, which any language or framework can send. The AI-assistant prompt just below adapts everything to yours.

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.co
1

Step 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;
You're done when your bookings table has a joyal_ref column that is empty on every booking so far.
2

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 starts https:// 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-EXPIRED and JY-TEST-OTHER (step 7). A live key refuses them.
You're done when opening your booking page with ?joyal_ref=JY-AB23CD45XY on the link and making a booking leaves that code on the booking.
3

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',
})
You're done when a test payment for a booking that carries a code comes back to your server with the same joyal_ref.
4

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.
You're done when a paid test booking sends one message and gets a 200 reply (with your test key, step 7).
5

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)
}
TypeSend it when
booking.createdThe booking is confirmed and paid.
booking.rescheduledIts date or time changes.
booking.cancelledIt is cancelled, or refunded in full.
You're done when moving and cancelling a test booking each send one message and get a 200 reply.
6

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.
You're done when a message sent while your server cannot reach Joyal goes through, once, after the connection comes back.
7

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 codeJoyal replies as if
JY-TEST-PAIDA customer tapped Book and paid: matched, with the points it would add.
JY-TEST-EXPIREDThe customer's code had run out: unmatched.
JY-TEST-OTHERThe 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.

You're done when all three test codes give their replies through your own outbox, for a create, a move and a cancel.
8

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.
You're done when a real booking made from the business's Joyal app gets a 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"
  }
}
FieldWhat it holds
idText, 1 to 100 characters. Unique to this message, and the same on every retry of it.
typebooking.created, booking.rescheduled or booking.cancelled.
occurred_atA time. When the change happened in your system, not when you sent it.
booking.idText, 1 to 100 characters. Your id for the booking. It never changes and is never reused.
booking.joyal_refText, exactly as it arrived on the link.
booking.starts_atA time. When the session starts. No more than a year ahead.
booking.ends_atA time. When the session ends: at least 1 minute and at most 24 hours after it starts. Points are added after this.
booking.amount_paidA 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.currencyEUR. Joyal only handles euros for now.
  • Times are ISO 8601 with a time zone: 2026-10-25T10:00:00Z or 2026-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.created once 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"}}.

ReplyIt meansWhat to do
200Received. The reply's outcome says what Joyal did, even when the booking earns nothing.Done. Mark it sent.
400Something 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.
401The key is missing or not recognised.Keep the message. Fix the key, then send it again.
422id_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.
429Too many messages this minute.Keep it and send it again in a minute.
500, or no reply in 10 secondsA problem on Joyal's side.Keep it and try again later (step 6).
OutcomeWhat Joyal did
matchedRecorded for the customer. points are on the way, added after credit_after.
heldRecorded, but waiting for the business to check it before any points are added.
unmatchedThe code was not usable (unknown, expired or another business's), so no points. detail says which.
switched_offPoints for bookings are off at this business, so nothing was recorded.
movedThe booking's times were changed.
cancelledThe booking was cancelled, and no points will be added for it.
already_cancelledJoyal already holds it as cancelled. Nothing changed.
already_creditedIts points were already added. Nothing changed.
not_heldJoyal 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.created that arrives late earns nothing.
  • Nothing about the customer is sent or stored beyond the code: no name, no email.
Questions, or ready to go live? Email info@joyal.co. Tell us the business you are connecting and the address of its booking site.