Skip to content
Developers

Build on Reservd.

Put a studio's timetable in your app, book people in, and hear about every booking the moment it happens. The API runs on the same booking engine as the Reservd booking pages, so capacity, waitlists and passes behave exactly the same.

Quick start

No code? Use the booking widget or the booking button. Building an app? A studio owner creates an API key in Dashboard → Your website and links → Developers, then:

curl https://www.reservd.co.uk/api/v1/classes \
  -H "Authorization: Bearer rsv_live_…"
{
  "data": [
    {
      "id": "8c1d…",
      "object": "class",
      "name": "Reformer foundations",
      "starts_at": "2026-10-01T08:30:00+00:00",
      "ends_at": "2026-10-01T09:20:00+00:00",
      "capacity": 8,
      "places_taken": 6,
      "places_left": 2,
      "waitlist": 0,
      "instructor": "Ella",
      "status": "scheduled",
      "drop_in_price_pence": 2400
    }
  ]
}

Booking widget

Paste this into any website. Customers see the timetable, sign in with their email and password (or create an account), join the studio, book, pay by card and cancel, all inside the page. It resizes itself.

<div data-reservd="your-studio"></div>
<script src="https://www.reservd.co.uk/embed.js" async></script>

Options on the div: data-theme="dark", data-accent="ff8a5c" (your brand colour, no #), data-days="14", data-view="passes". In an app, open https://www.reservd.co.uk/widget/your-studio in a web view.

Booking button

<a href="https://www.reservd.co.uk/s/your-studio" data-reservd-button>Book a class</a>
<script src="https://www.reservd.co.uk/button.js" async></script>

It opens booking in a pop-up over your page. Add data-theme="light" for dark backgrounds, data-plain to keep your own button style, or data-class with a class id to open one class. For Instagram and link-in-bio pages, just use the link https://www.reservd.co.uk/s/your-studio.

Calendar feeds

Subscribe in Google, Apple or Outlook Calendar. Feeds refresh on their own.

/cal/studio/your-studio.icsThe whole timetable.
/cal/studio/your-studio.ics?instructor=EllaOne instructor's classes, with how full they are.
/cal/me/<private token>.icsA member's own bookings across every studio. Members find their link on My bookings.

Your own web address

A studio can run its booking pages, customer accounts and payments on its own address, such as book.yourstudio.co.uk. Add the address in Dashboard → Your website and links, then add the DNS record shown there (usually a CNAME) where the domain is registered. The SSL certificate is set up for you. On that address, / is the timetable, /me the customer's account, and the customer API works too.

Customer API

For building booking, payments and customer accounts into your own website or app, with your own design. Customers sign in with their email and password, the same Reservd account they use everywhere; every call then runs as that customer, so they only ever see their own data. There's no studio key involved, so it's safe to call straight from a browser or an app, and it answers any website (CORS is open).

Base address: https://www.reservd.co.uk/api/customer/v1. Send Authorization: Bearer <access_token> once signed in. Money is in pence and times are ISO 8601 in UTC. Errors look like { "error": { "code": "class_full", "message": "…" } }; the message is written for customers, so you can show it as it is.

RequestWhat it does
POST/auth/sign-upCreate an account: { full_name, email, password, accept_terms: true, studio? }. Passwords are at least 8 characters. 201 with tokens, or 202 { confirm_email: true } when they need to open the link we email first.
POST/auth/sign-inSwap { email, password } for { access_token, refresh_token, expires_at, user }.
POST/auth/password-resetEmail a link to set a new password: { email, studio? }. Always { sent: true }, whether or not there's an account.
POST/auth/resend-confirmationSend the confirm-your-email link again: { email, studio? }.
POST/auth/refreshSwap { refresh_token } for a new access token.
POST/auth/quick-bookBook with just an email: { full_name, email, accept_terms: true, studio? } makes a new account without a password and returns its tokens (201, with quick: true), so the booking and payment can go ahead straight away. account_exists (409) when the email already has an account: sign in with the password or send a reset link. See Book with just an email below.
GET/studios/{slug}The studio, its booking rules, joining questions, waiver and what it sells. Public.
GET/studios/{slug}/classesClasses day by day with places left. ?from=YYYY-MM-DD&days=7. Signed in, prices are the customer's own.
GET/studios/{slug}/classes/{id}One class, plus the customer's booking (with waitlist_position and waitlist_count while they wait) and the pass that would be used.
GET/studios/{slug}/classes/{id}/similarClasses like this one with a place free and booking open, to offer when it's full or cancelled. ?when=same_time (90 minutes either side, the default) or soon (the next 7 days), &limit= up to 20. Other studios only appear when both are in the directory and in the same town.
GET/studios/{slug}/classes/{id}/watchWhether the customer is watching a full class: { watching }.
POST/studios/{slug}/classes/{id}/watch"Just watch it, no charge": one push notification when a place opens on this full class that nobody on the waitlist takes. Nothing is booked or held. Refused with class_not_full while there are places. DELETE stops watching.
POST/studios/{slug}/joinJoin the studio in one call: details, answers to the studio's questions, health answers and signed waiver. A yes to a health question flags the customer for the studio to review. Also finishes the joining form for someone who booked first.
GET/studios/{slug}/joining-formWhether the joining form is still due (they booked before filling it in), the class it's due before, and the questions, health questions and waiver to show.
GET/studios/{slug}/meThe customer's account: coming up (with waitlist places), past, to pay, memberships, passes, account credit, card, family, weekly bookings, doors, member.joining_form_due and their referral link. Memberships and passes with classes a month or week carry classes_left, classes_this_period (with any carried over), classes_per_period, refills_on (null once it stops renewing) and classes_line, like "5 of 8 classes left this month, refills 1 Dec".
POST/studios/{slug}/bookingsBook { class_id, pass_id?, for_member_id? }. Full classes join the waitlist. Where the studio allows it, new customers can book before joining (claim_trial?, referral_code?); joining_form_due tells you to show the form next. When the studio takes payment at booking (rules.pay_when_booking), a paid place comes back with pay_by: take the payment straight away, as the place is let go if it isn't paid by then.
GET/coachesPersonal trainers and 1:1 coaches in the directory, nearest first, each with up to 3 next free times. ?city= or ?lat=&lng=&radius_km=, goal, place, gender, max_price, date, q, limit, offset. Public. See Personal trainers and coaches below.
GET/studios/{slug}/appointment-types1:1 sessions the customer can book: price, length, online or not, rules (min_notice_hours, days_ahead for this customer, slot_interval_min, and words to show) and the booking form questions. Public. See 1:1 sessions below.
GET/studios/{slug}/appointment-types/{id}/slotsFree start times for a 1:1 type: ?date=YYYY-MM-DD (the studio's local date, today by default). Each slot has starts_at, ends_at, coach, where, price_pence and usual_price_pence (lower price_pence is a last-minute price). Small groups people can join come too, with joinable, places_left, group_size and session_id; ?join=<code> from a share link adds a private one. The type's rules are already applied. Public.
POST/studios/{slug}/appointmentsBook a 1:1: { appointment_type_id, starts_at, coach, pass_id?, for_member_id?, answers?, open? }, or join a small group with { appointment_type_id, session_id, join_code?, ... }. Starting a small group, open: false keeps it to people with its share link (small_group.path in the reply). answers is { question id: answer }; required questions must be answered and a choice must be one of its options. Paid sessions come back with pay_by: pay straight away with /payments. booking.class_id is the session, for /classes/{id} and cancelling.
GET/studios/{slug}/bookings/{id}/answersThe customer's own answers to a 1:1's booking form: [{ id, question, answer, sensitive }].
POST/studios/{slug}/bookings/{id}/cancelCancel a booking. A paid class cancelled in time comes back refunded when the studio gives account credit.
POST/studios/{slug}/paymentsStart paying for { booking_id | product_id | enrolment_id | resource_booking_id }, with an optional promo_code. Returns Stripe's embedded checkout.
POST/studios/{slug}/payments/confirmCall when checkout finishes, so it shows straight away. Body: { session_id }.
GET/studios/{slug}/memberships/{id}When the membership would end if cancelled today: { ends_on, at_period_end, reason, message }. Show message before they confirm.
POST/studios/{slug}/memberships/holdFor a flagged customer buying a membership marked needs_health_clearance: save payment details with a Stripe SetupIntent for the native payment sheet. Nothing is charged until the studio clears them.
POST/studios/{slug}/memberships/hold/confirmCall when the payment sheet finishes. Body: { hold_id }.
DELETE/studios/{slug}/memberships/holdCancel a held membership before it starts. Body: { hold_id }.
GET/studios/{slug}/health-reviewsStaff only: customers waiting for a decision on their health answers, limited to the ones you can see. No answers.
GET/studios/{slug}/health-reviews/{member_id}Owners, managers and the customer's assigned coaches: their health answers, joining answers and past decisions. Every call is logged.
POST/studios/{slug}/health-reviewsOwners, managers and assigned coaches: { member_id, decision: cleared | cleared_with_changes | see_gp, note? }. Clearing starts any held membership.
GET/studios/{slug}/members/{member_id}/coachesStaff only: who is assigned to the customer. Assigned staff can see their health answers.
POST/studios/{slug}/members/{member_id}/coachesOwners and managers: assign a member of the team, { user_id, label? }.
DELETE/studios/{slug}/members/{member_id}/coachesOwners and managers: take them off again, { user_id }.
GET/studios/{slug}/progress-photosThe customer's own progress photos, newest first: { photos: [{ id, taken_on, angle, note, url, created_at }], sharing: { photos, progress } }. url is a signed link that works for an hour; angle is front, side, back or null.
POST/studios/{slug}/progress-photosAdd a photo in two steps. { content_type?, angle?, note?, taken_on? } returns a one-time upload slot { path, token, bucket, upload_url, max_bytes, expires_in }: upload with Supabase's storage.from(bucket).uploadToSignedUrl(path, token, file). Then { confirm: path, angle?, note?, taken_on? } saves it and returns 201 { photo }. JPEG, PNG, WebP or HEIC, up to 8 MB; each path is saved once (photo_already_saved).
DELETE/studios/{slug}/progress-photos?id=Delete one of their photos and its file.
GET/studios/{slug}/progress-photos/sharingWhether the studio's coaches can see their progress: { photos, progress }. Photos are private until the customer shares them.
POST/studios/{slug}/progress-photos/sharingChange it: { photos?, progress? }. Only send photos: true when the customer taps to say yes.
GET/studios/{slug}/members/{member_id}/progress-photosOwners, managers and the customer's assigned coaches, once the customer shares their photos: { member_id, photos } as above. Every call is logged in their health access log before any link is made; 403 not_allowed or progress_not_shared otherwise.
GET/studios/{slug}/members/{member_id}/progressThe same people, while the customer shares their progress: { member_id, entries: [{ id, recorded_on, kind, exercise, value, reps, unit, note, logged_by_staff, created_at }] }, oldest first. Logged the same way.
POST/studios/{slug}/memberships/{id}Freeze, unfreeze, cancel, keep, switch plan, or get a link to update payment details.
POST/studios/{slug}/cardSave a card for waitlist places and late cancellation and no-show fees. DELETE removes it.
POST/studios/{slug}/familyAdd a child the customer books for.
POST/studios/{slug}/gift-cardPay with a gift card: { code, booking_id | product_id | enrolment_id }.
POST/devicesTurn on push notifications for this phone: { token, platform, studio? }. token is the Expo push token; studio is your slug.
DELETE/devicesTurn them off again when the customer signs out: { token }.
GET/alerts/spare-placesThe customer's spare-place alert settings (off until they switch them on) and the choices to offer.
POST/alerts/spare-placesChange them: { enabled?, one_to_one?, categories?, where?, areas?, near?, radius_km?, windows?, max_per_day?, quiet_start?, quiet_end?, source? }. one_to_one: true adds last-minute 1:1 times with coaches (off until they say yes).
DELETE/alerts/spare-placesTurn spare-place alerts off in one tap. Their choices are kept.
GET/notifications?limit=&before=The customer's recent notifications, newest first, and how many are unread: the same words and link as each push they were sent. Each has kind, title, body, action ({ label, path }, like "Book · £12" or "Show pass"), studio, created_at and read.
POST/notifications/readMark them read: { ids? }. Leave ids out to mark all read. Returns { unread }.
GET/notifications/settingsTheir notification settings: a switch per type, grouped by what it's for, activities, quiet hours (21:00 to 07:00 by default), email copies, muted studios and when each type was last sent.
POST/notifications/settingsChange them: { reminder?, cancel_window?, waitlist?, booking_opens?, studio_updates?, new_near_you?, favourites?, instructors?, categories?, near?, radius_km?, quiet?, quiet_start?, quiet_end?, email_copy?, mute? }. mute is { studio, on }. Changes and cancellations always stay on.
GET/helpMessage Reservd: their own conversation with the Reservd team, { status, unread, messages }, marked read. ?after=<at> gives only newer messages; ?unread=1 gives just { unread }. Not for a studio's own app.
POST/helpSend Reservd a message: { body, booking? } (up to 4000 characters, 20 an hour; booking must be theirs or their child's). A person replies, by push (type help_reply) and email.
GET/bookingsEvery booking across Reservd for the customer and their children, the last 120 days and all to come: class, studio (address and map point), status, waitlist place and auto_book, the refund on a cancelled one, any late cancel or no-show fee, their rating and the studio's reply, can_rate, next_week (the same class next week with its price) and similar classes when the studio cancelled. Plus book_again. A studio's own app only gets that studio's.
GET/passesThe next few places they hold, each with its check-in QR as { text, size, rows } to draw and keep on the phone, so the pass works without signal. allowances lists memberships with classes a month or week (classes_left, classes_this_period, classes_per_period, period, refills_on, frozen, line). wallet says whether wallet cards can be added.
POST/bookings/{id}/waitlistTurn waitlist auto-book on or off: { auto_book }. Off keeps their place in line without booking them in; on again takes a free place straight away.
POST/bookings/{id}/ratingRate a class they came to, up to two weeks after: { rating: 1 to 5, tags?, comment? }. Tags: great_instructor, beginner_friendly, calm, small_class, sweaty, good_music, clean_studio, easy_to_find.
POST/bookings/{id}/problemReport a problem with a class to Reservd's team: { message }. A person reads every one.
POST/bookings/{id}/inviteInvite a friend to a class they've booked: returns { url, text, code }. url is their own link (reservd.co.uk/c/<code>), showing their first name, the class, places left and the price, and booking it; text is the words to share with it. The same booking always gets the same link, 30 new ones a day (429 invite_limit). A friend who books through it counts as brought by a member, and the customer gets one notification when they do.
GET/ratings/{token}No sign-in. The class a rating link is for (the link in the rate-your-class email and push ends in the token). state: ok, used, expired or invalid; reviewer is how the review is signed.
POST/ratings/{token}No sign-in. Rate with the link: { rating, tags?, comment? }. Each link rates once and runs out two weeks after the class.
POST/ratings/{token}/problemNo sign-in. Report a problem from the rating link: { message }.
GET/walletWhich phone wallets a booking can be added to: { apple, google }. No sign-in needed. The pass opens signed in on the website at /api/wallet/pass?platform=apple|google&slug=&booking=.
POST/run-club/bookings/{id}/activityReservd Run Club: send the run recorded on the phone for one of their own run bookings, { points: [{ t, lat, lng, alt?, acc? }] } (t in milliseconds since 1970, alt and acc in metres). Up to 20,000 points in time order, no longer than 6 hours, within 12 hours of the run's start. We work out distance, moving time, pace, splits per km, climb and a thinned line, and keep them; sending again replaces them. Returns { activity }. See Run Club below.
GET/run-club/bookings/{id}/activityTheir recorded run for that booking: { activity }, or 404 activity_not_found.
GET/run-club/bookings/{id}/stravaReservd Run Club: where that run is on Strava, { strava: { strava_activity_id, kind (renamed or uploaded), status (uploading, done or failed) } | null }. See Run Club below.
GET/run-club/stravaReservd Run Club: their Strava connection, { strava: { connected, first_name, athlete_id, rename_runs, upload_runs, needs_reconnect, connected_at }, available }. available is false while connecting Strava is coming soon.
PATCH/run-club/stravaTheir Strava choices, { rename_runs?, upload_runs? } (true or false; missing ones are kept). Returns { strava }, or 404 strava_not_connected.
DELETE/run-club/stravaDisconnects Strava: takes away Reservd's access at Strava and deletes the connection and any points waiting to be sent. Returns { strava: { connected: false } }.

Sign in, book and pay

const API = "https://www.reservd.co.uk/api/customer/v1";
const post = (path, body, token) =>
  fetch(API + path, {
    method: "POST",
    headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
    body: JSON.stringify(body),
  }).then((r) => r.json());

// New customers: post("/auth/sign-up", { full_name, email, password, accept_terms: true, studio: "your-studio" })
const { access_token, error } = await post("/auth/sign-in", { email, password });
// error.code: invalid_credentials, email_not_confirmed or too_many

const s = "/studios/your-studio";
const { booking } = await post(s + "/bookings", { class_id }, access_token);

if (booking.payment_status === "due") {
  const pay = await post(s + "/payments", { booking_id: booking.id }, access_token);
  const stripe = await loadStripe(pay.publishable_key, { stripeAccount: pay.stripe_account });
  const checkout = await stripe.createEmbeddedCheckoutPage({
    clientSecret: pay.client_secret,
    onComplete: () => post(s + "/payments/confirm", { session_id: pay.session_id }, access_token),
  });
  checkout.mount("#pay");
}

New customers get "member": null from /me and a not_a_member error when booking: show your join form, with the health questions and waiver from GET /studios/your-studio, and send it to /join. Access tokens last an hour; swap the refresh token at /auth/refresh to stay signed in.

Accounts and passwords

Customers have one Reservd account for every studio, with the same email and password as on reservd.co.uk and in the Reservd app. Show Reservd's member terms and privacy policy with a tick box on your sign-up screen and send accept_terms: true. When email confirmation is on, /auth/sign-up answers 202 and /auth/sign-in answers email_not_confirmed until they open the link. For a forgotten password, call /auth/password-reset: the email links to a page where they choose a new password, then back to the studio (its own web address if it has one, when you send studio). Wrong passwords and account emails are rate limited per email and per address; too_many (429) means wait a few minutes.

Book with just an email

For a new customer, /auth/quick-book takes their name and email and signs them in, with no password. Say "By booking you agree to Reservd's member terms and privacy policy" (with the links) next to the button, and send accept_terms: true. Then book and pay as usual. We email them the booking with a link to confirm their email and choose a password; offer "Add a password (optional)" after booking too (Supabase's updateUser with the access token).

An email that already has an account never signs anyone in: it answers account_exists, and the customer signs in with their password or uses /auth/password-reset. Until they open the emailed link (or any reset link), the account can book up to 3 classes to come, pay for those, and see and cancel its bookings. Saving a card, buying packs or memberships, adding children, booking friends and deleting the account answer confirm_email_first; a fourth booking answers quick_account_limit. It's limited to 5 new accounts an hour (15 a day) from one address and 3 tries an hour for one email (too_many), and throwaway addresses answer email_disposable.

Join from an app

GET /studios/your-studio returns joining: the studio's own questions (each has an id, text, type of text or choice with options, and required), the health_questions and the waiver. Build your own screens for them, then send everything to /join in one go. The waiver is signed by typing the full name (signed_name) and agreeing (waiver_agreed: true). The emergency contact is a name, a relationship and a phone number.

const { member } = await post(s + "/join", {
  full_name: "Sam Smith",
  phone: "07700 900123",
  answers: { what_are_your_goals: "Run a 10k", days_a_week_you_can_train: "3" },
  health: { q1: false, q2: false, q3: false, q4: false, q5: false, q6: true, q7: false,
            details: "Knee operation last year", consent: true },
  emergency_contact: { name: "Alex Smith", relationship: "Partner", phone: "07700 900456" },
  signed_name: "Sam Smith",
  waiver_agreed: true,
}, access_token);
// member.health_flag: true if any health answer was yes
// member.health_review: "pending" until the studio decides

Book first, fill in the joining form after

Studios can let new customers book a class before they fill in the joining form. When rules.book_before_joining_form is true on GET /studios/your-studio, /bookings works for someone who hasn't joined: it makes a minimal member record from their account (name and email) and books them. Send claim_trial: true to use the studio's free trial class, and referral_code if they came from a friend's link. The response has joining_form_due: true: take them to your joining form straight away. It only covers single classes; courses, weekly bookings and memberships that need health clearance wait for the form (joining_form_due errors). We remind them by email and push soon after booking, the day before and a few hours before class, and the studio sees “Joining form not done” on its class list.

const get = (path, token) => fetch(API + path, { headers: { Authorization: "Bearer " + token } }).then((r) => r.json());

const { booking, joining_form_due } = await post(s + "/bookings", { class_id, claim_trial: true }, access_token);
if (joining_form_due) {
  const form = await get(s + "/joining-form", access_token);
  // { due: true, next_class: { class_id, name, starts_at }, member: { full_name },
  //   questions, health_questions, waiver }
  // Show it, then send the answers to /join exactly as for joining.
  await post(s + "/join", { full_name, answers, health, signed_name, waiver_agreed: true }, access_token);
  // member.joining_form_due is now false
}

Referral links

When a studio runs a referral programme, GET /studios/your-studio has referrals (the credit the member gets and the friend's offer) and /me has referral: the customer's code, link, a line of text to show beside it, and how many friends have joined. If your app opens with a friend's code, pass it as referral_code to /join or /bookings. Rewards are the studio's own account credit and appear in account_credit.

Class details and facilities

Every class from /classes and /classes/{id} has level (all_levels, beginner_friendly, intermediate or advanced) with a level_label to show, what_to_bring (a few lines of text, or null), arrive_minutes_before (or null) and arrive_by, the time to arrive worked out from it. GET /studios/{slug} has amenities, the studio's facilities as { key, label } from a fixed list (showers, changing rooms, lockers, towels, mats provided, equipment hire, parking, bike storage, step-free access, accessible toilet, water refill, café), and first_visit_notes: how to find them and what happens on a first visit.

{ "level": "beginner_friendly", "level_label": "Beginner friendly",
  "what_to_bring": "Water and grip socks", "arrive_minutes_before": 10,
  "arrive_by": "2026-11-02T08:50:00.000Z" }

Personal trainers and coaches

Trainers and coaches sell 1:1 sessions rather than a timetable. GET /studios/{slug} has coach: when one_to_one is true, lead with the coach instead of a timetable. It has their name and photo_url, goals they help with as { key, label } from a fixed list (fat_loss, strength, muscle, fitness, rehab, mobility, pre_postnatal, running, sport, older_adults, beginners, nutrition), qualifications, years coaching, gender (only when they chose to show it), places they train (gym, park, studio, home_visits with radius_km, or online; with lat and lng when known), packages (products to show first, with highlight, what's included; buy them as any product), results (before and after photos and a story, only ones the client agreed to show), and sessions: each 1:1 type with its next three free times in the coming week as next. consultation_id is their free 15-minute consultation, when they offer one; book it like any 1:1 session. Products also have highlight and featured_order.

"coach": { "one_to_one": true, "name": "Jo Fraser",
  "goals": [{ "key": "strength", "label": "Strength" }],
  "qualifications": ["Level 3 Personal Trainer"], "years": 6, "gender": null,
  "places": [{ "kind": "gym", "name": "PureGym Hove", "postcode": "BN3 2FL",
    "lat": 50.83, "lng": -0.17, "radius_km": null, "detail": "Gym, BN3 2FL" }],
  "sessions": [{ "id": "…", "name": "Personal training", "duration_min": 60,
    "price_pence": 4500, "is_consultation": false,
    "next": [{ "starts_at": "2026-11-03T07:00:00Z", "instructor_name": "Jo Fraser" }] }],
  "consultation_id": "…" }
GET /api/customer/v1/coaches?city=Hove&goal=rehab,strength&limit=12
{ "total": 3, "limit": 12, "offset": 0,
  "coaches": [{ "slug": "jo-fraser", "name": "Jo Fraser Coaching", "coach_name": "Jo Fraser",
    "goals": [{ "key": "rehab", "label": "Injury rehab" }], "rating": null, "ratings": 4,
    "from_price_pence": 4000, "last_minute_price_pence": 3200, "has_consultation": true,
    "where": "PureGym Hove · Home visits BN3", "distance_km": 0, "comes_to_you": true,
    "next_times": [{ "appointment_type_id": "…", "starts_at": "2026-11-03T07:00:00Z",
      "coach": "Jo Fraser", "price_pence": 3200, "usual_price_pence": 4000, "joinable": false,
      "booking_url": "https://www.reservd.co.uk/s/jo-fraser/appointments?type=…&at=…" }] }] }

1:1 sessions

Personal training and other 1:1 sessions are booked from free times rather than a timetable. List the types with /appointment-types, then ask /appointment-types/{id}/slots?date= for each day you show (up to rules.days_ahead days from today). The business's rules are already applied to the slots: how much notice it needs, how far ahead people can book, how often sessions start, time kept free around each one and a daily limit per coach. Show the questions before booking: required ones must be answered, a choice takes one of its options, and sensitive ones are kept like health answers (only the customer, owners, managers and assigned coaches see them). Book with POST /appointments. A time that has just gone comes back slot_taken; others are appointment_notice, appointment_too_far, appointment_daily_limit, answer_required and answer_not_an_option. Online sessions show booking.stream_url (the video call link) on /classes/{class_id} once booked.

Small groups: a type with group set takes up to group.size people at group.price_pence_each each. The first person books a free time as usual (add open: false for "Just me and friends"); while it has places and hasn't started, the same time comes back from /slots with joinable: true, places_left and session_id, and POST /appointments { session_id } joins it. Show the reply's small_group.path as the link to share; a private group needs its join_code. Errors: group_full, group_private, group_not_found. A type's last_minute price applies to free times starting within last_minute.hours; the slot's price_pence is what's charged.

const { appointment_types } = await get(s + "/appointment-types");
const pt = appointment_types[0];
const { slots } = await get(s + "/appointment-types/" + pt.id + "/slots?date=2026-11-03");
const { booking } = await post(s + "/appointments", {
  appointment_type_id: pt.id, starts_at: slots[0].starts_at, coach: slots[0].coach,
  answers: { what_would_you_like_to_work_on: "Getting stronger" },
}, access_token);
if (booking.pay_by) await post(s + "/payments", { booking_id: booking.id }, access_token);

Memberships that wait for health clearance

Studios can mark a monthly or weekly membership as needing health clearance (needs_health_clearance on each product). When a customer whose health answers are flagged buys one, don't take a payment: call /memberships/hold and show Stripe's payment sheet in setup mode. It saves their card or direct debit details on the studio's own Stripe account. The membership starts, and the first payment is taken, when the studio clears them. If the studio says they need a GP note first, nothing is charged. /me shows member.health_review and any held_memberships.

const hold = await post(s + "/memberships/hold", { product_id }, access_token);
// hold_not_needed (409)? They aren't flagged: pay as normal with /payments.
await initStripe({ publishableKey: hold.publishable_key, stripeAccountId: hold.stripe_account });
await initPaymentSheet({
  setupIntentClientSecret: hold.setup_intent,
  customerId: hold.customer,
  customerEphemeralKeySecret: hold.ephemeral_key,
  merchantDisplayName: hold.merchant_display_name,
});
const { error } = await presentPaymentSheet();
if (!error) await post(s + "/memberships/hold/confirm", { hold_id: hold.hold_id }, access_token);
// → { status: "waiting" }: saved, starts when the studio clears them

Who sees health answers

Health answers are only for the studio's owners and managers and the staff assigned to that customer (for example their 1-to-1 coach). Other staff see that someone has a health note, never what it says. Owners and managers assign coaches on the member's page in the dashboard or with /members/{member_id}/coaches. Every time staff open a customer's answers it's written to that customer's health access log, which the studio's owners and managers and the customer themself can read.

const get = (path, token) => fetch(API + path, { headers: { Authorization: "Bearer " + token } }).then((r) => r.json());

const { reviews } = await get(s + "/health-reviews", staff_token);
// [{ member_id, full_name, health_review, flagged_at,
//    held_membership: { product_id, name, status } | null }]
const one = await get(s + "/health-reviews/" + reviews[0].member_id, staff_token); // logged
// { health_questions, member: { id, full_name, health_flag, health_review, health_reviewed_at },
//   health: { answers, signed_at, signed_name } | null,
//   answers: { questions, answers, signed_at } | null,
//   reviews: [{ id, decision, note, reviewed_by_name, created_at }] }
// 403 not_allowed if you aren't an owner, a manager or their assigned coach.

await post(s + "/members/" + member_id + "/coaches", { user_id, label: "1-to-1 coaching" }, owner_token);
// { coaches: [{ user_id, name, email, role, label, created_at }] }

Staff decide on the member's page in the dashboard, or from their own app with /health-reviews while signed in with their staff account. /payments with a held membership's product_id also works: it returns a checkout that only saves payment details, with on_hold: true.

Building an app for one studio? Send x-reservd-scope: your-studio with every request, here and to Supabase if you call it directly. Your app then only ever sees and books that studio, even if the customer belongs to others. Push notifications (booked from the waitlist, class reminders, class cancelled, payment failed) go to phones registered at /devices through Expo's push service; register with your studio's slug so the phone only hears about your studio.

Spare-place alerts are Reservd's own: a push notification like "Reformer at 12:30 in Hove · 2 places · £12" when a class the customer would like has space 2 to 4 hours from now. They're off until the customer switches them on, so only send "enabled": true when they tap to say yes (we keep when). categories are activity slugs; where is anywhere, areas (with areas, names from the choices) or near (with near: { lat, lng, label } and radius_km of 1, 3 or 5); windows are morning, lunchtime, afternoon, evening (Monday to Friday) and weekend, none meaning any time; max_per_day is 1 (the default), 2 or 3; quiet hours are whole UK hours, 21 to 7 unless changed. They go to the Reservd app and browsers, never a studio's own app, so requests with x-reservd-scope are refused. The notification's data.url is the class page, or /out/{slug} for a business that takes bookings on its own website.

Run Club

Reservd Run Club is our own free Monday 5km. Runners record their run in the Reservd app, which sends the GPS points when they finish. Points with accuracy worse than 30 m, and jumps faster than 10 m/s, are left out; pauses (slower than 0.5 m/s, or a gap over 10 seconds with little movement) don't count towards moving time; climbs under 3 m don't count. Refused with points_required, bad_points, too_many_points (413), points_out_of_order, run_too_long, not_this_run, no_usable_points, not_a_run or booking_not_found (404). The runner's recap image is at /api/run-club/recap/{booking}?format=story|post with the same bearer token.

const { activity } = await post("/run-club/bookings/" + booking_id + "/activity", { points }, access_token);
// { booking_id, run_id, run_number, host_name, starts_at, started_at, ended_at,
//   distance_m, moving_s, elapsed_s, elevation_gain_m, avg_pace_s (seconds a km, or null),
//   splits: [{ km, distanceM, paceS, elevationGainM }],
//   track: { type: "LineString", coordinates: [[lng, lat], …] } | null,
//   strava_activity_id, stamped }

Runners can connect Strava. Connecting is on the website, signed in: open /runclub/strava/connect?from=app (the Reservd app signs the browser in first), and Strava sends them back to reservd://run-club?strava=connected, or reservd://run-club?strava=error&reason=… with coming_soon, denied, scopes (they unticked a box we need), state or failed. With rename_runs on, a run their watch sends to Strava that starts from 30 minutes before to 45 minutes after a club run they checked in at is named "Reservd Run Club #8 · Host" when it still has a name Strava made up. With upload_runs on, a run recorded in the app and sent to the activity route above answers strava: "queued" and is uploaded to Strava with that name; otherwise strava is null. We keep nothing from Strava but ids, and only the runner sees them: link them to https://www.strava.com/activities/{strava_activity_id} as "View on Strava".

API keys

Send the key in the Authorization header as Bearer rsv_live_…. Each key belongs to one studio and can only see that studio. Keep keys on your server: don't put them in a website or mobile app where people can read them. Revoke a key any time in Dashboard → Your website and links → Developers.

Requests and responses are JSON. Times are ISO 8601 in UTC; show them in the studio's timezone. Money is in pence. Lists return { "data": [...], "has_more": true } and take limit (up to 200) and offset.

Endpoints

RequestWhat it does
GET/api/v1/studioYour studio's details and booking rules.
GET/api/v1/classes?from=&to=The timetable with places left. 14 days from now by default, up to 92 days per request.
GET/api/v1/classes/{id}One class.
GET/api/v1/class-typesClass types (the things on your timetable).
GET/api/v1/productsPacks, memberships and drop-in products.
GET/api/v1/members?email=&user_id=&q=&limit=&offset=Find members by exact email or Reservd user_id (the signed-in customer's id), or search name and email with q. Each member has user_id (null if they have no login).
POST/api/v1/membersAdd a member. Body: full_name, email, phone (optional).
GET/api/v1/members/{id}One member.
PATCH/api/v1/members/{id}Change full_name or phone.
GET/api/v1/members/{id}/passesA member's packs and memberships, with classes left. provider and external_ref are set on passes paid for outside Reservd.
POST/api/v1/members/{id}/passesGive a pass paid for outside Reservd, like an in-app purchase: { product_id, provider: app_store | play_store | other, external_ref, expires_on }. Packs and memberships (monthly too), not drop-ins. No payment is recorded and Reservd takes no fee. Safe to repeat: the same external_ref returns the same pass, and a later expires_on extends it (a renewal). 201 when new, with created and extended.
PATCH/api/v1/members/{id}/passes/{pass_id}End or change a pass given that way: { status: "cancelled" } for a refund, or { expires_on } (a date already passed makes it expired). Passes paid through Reservd are refused with pass_not_external.
GET/api/v1/members/{id}/programmeThe member's active programme this week, or null: { enrolment_id, programme_id, name, weeks, started_on, goal, week, workouts: [{ item_id, title, done, done_today }], checked_in, checkin_rating }. week is in the studio's time zone; workouts are this week's. No check-in answers.
GET/api/v1/bookings?class_id=&member_id=&status=Bookings, newest first.
POST/api/v1/bookingsBook a member in. Body: class_id, member_id, pass_id (optional). Full classes put them on the waitlist.
GET/api/v1/bookings/{id}One booking.
DELETE/api/v1/bookings/{id}?apply_rules=trueCancel. Without apply_rules it's a staff cancel with no late fee.
POST/api/v1/bookings/{id}/check-inCheck someone in. DELETE on the same address undoes it.
GET/api/v1/events?since=&type=The event log, if you'd rather poll than use webhooks.

Book someone in

curl -X POST https://www.reservd.co.uk/api/v1/bookings \
  -H "Authorization: Bearer rsv_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "class_id": "8c1d…", "member_id": "41fa…" }'

The response is the booking. Its status is booked, or waitlisted if the class was full. Booking statuses: booked, waitlisted, cancelled, late_cancelled, attended, no_show.

Errors

HTTP 409
{ "error": { "code": "already_booked", "message": "You're already booked on that class." } }

Codes include missing_key, invalid_key (401), invalid_id, missing_field (400), member_not_found, booking_not_found, class_not_found (404), already_booked, no_credits_left, weekly_limit_reached, not_cancellable (409).

Webhooks

Add an https address in Dashboard → Your website and links → Developers. We send a POST with a JSON body for each event you choose. Reply with any 2xx within 10 seconds. If you don't, we retry after 1, 5 and 30 minutes, then 2, 6, 12 and 24 hours.

POST /your-endpoint
Reservd-Event: booking.created
Reservd-Signature: t=1790280000,v1=5f2b…

{
  "id": "e7a0…",
  "type": "booking.created",
  "created_at": "2026-09-25T18:02:11Z",
  "studio_id": "0b6c…",
  "data": {
    "booking": { "id": "…", "status": "booked", "payment_status": "due", "amount_pence": 1400 },
    "class":   { "id": "…", "name": "Vinyasa flow", "starts_at": "…" },
    "member":  { "id": "…", "full_name": "Maya Rose", "first_name": "Maya", "last_name": "Rose", "email": "maya@example.com" }
  }
}

Every member in an event has full_name, and first_name and last_name split from it: the first word, then the rest. last_name is null when the name is one word.

EventWhen
booking.createdA place is booked
booking.waitlistedSomeone joins a waitlist
booking.promotedSomeone moves off the waitlist
booking.cancelledA booking is cancelled
booking.checked_inSomeone checks in
booking.no_showSomeone doesn't turn up
booking.paidA booking is paid
booking.confirmedA customer's booking is paid, or confirmed with nothing to pay
waitlist.charge_failedA card charge for a waitlist place fails
credit.addedA member gets account credit
member.createdA member joins
member.updatedA member's details change
payment.succeededA payment goes through
payment.failedA payment fails
payment.refundedA payment is refunded
pass.createdA pack or membership starts
membership.startedA monthly or weekly membership starts
membership.cancelledA membership ends
lead.createdA new lead comes in
pass.updatedA pack or membership changes
class.cancelledA class is cancelled
rating.createdSomeone rates a class
rating.repliedYou reply to a rating

Checking signatures

Every request is signed with your endpoint's secret (whsec_…). Check it before trusting the body, and reject anything older than five minutes.

import crypto from "node:crypto";

function isFromReservd(rawBody, header, secret) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Use the raw request body, before any JSON parsing. The same event can arrive more than once, so use its id to ignore repeats.