Wellness Frontier · Developers
Build on a salon's Online Booking
Every salon with Online Booking has a booking page. With the salon's key you can show its free times and take bookings in your own site or app, and with a webhook you hear about every booking as it is made, moved or cancelled. The salon's rules always apply: the same hours, notice and team as on its page.
Getting started
- The salon's owner opens Online Booking › Settings › For developers and creates a key, choosing
read,bookor both. - They copy the key once and give it to you. It starts
wf_live_. - Ask for free times, then book one of them.
curl "https://wellnessfrontier.co/api/v1/booking/availability?serviceId=<service id>&day=2026-11-12" \ -H "Authorization: Bearer wf_live_…"
Only need a Book now key on a website? The embed script needs no key at all.
Authentication
Send the key in the Authorization header as Bearer wf_live_…. A key belongs to one salon; it can only ever reach that salon.
Scopes
read: Services, professionals and free times (GET availability).book: Create bookings (POST bookings).
Keeping keys safe
- We keep only a fingerprint of each key, so a lost key can't be shown again: the owner revokes it and makes a new one.
- Keyed calls answer any website (CORS), and send no cookies. A
readkey may sit in a web page; keep abookkey on your server. - A revoked key stops working at once.
Free times
GEThttps://wellnessfrontier.co/api/v1/booking/availabilityKey scope: read
The salon, its services and professionals; with serviceId and day, that day's free start times too.
Query
services. With day, the answer includes free times.anyone (the default) for the first one free.salon.locations. Without it, the salon's main location.Answer
serviceId and day were asked for.Salon
<slug>.wellnessfrontier.vip.Africa/Johannesburg.priceCents. Null: show no prices.Service
serviceId.salon.currency; null when not shown.Professional
professionalId.Slot
startsAt to book.Bookings
POSThttps://wellnessfrontier.co/api/v1/booking/bookingsKey scope: book
Books one of the offered times. The salon and client get the same confirmation emails as from the booking page, and the booking appears in the salon's Scheduler.
Body
anyone.slots[].startsAt.Client
curl -X POST "https://wellnessfrontier.co/api/v1/booking/bookings" \
-H "Authorization: Bearer wf_live_…" \
-H "Content-Type: application/json" \
-d '{"serviceId":"5b1d0c3e-2f4a-4d0b-9a51-0c9e7f3a1b22","professionalId":"anyone","startsAt":"2026-11-12T08:00:00.000Z","client":{"firstName":"Lerato","lastName":"Mokoena","email":"lerato@example.com","mobile":"+27 82 555 0101"}}'Answer (201)
WF-7K3QX2.{
"ok": true,
"reference": "WF-7K3QX2",
"startsAt": "2026-11-12T08:00:00.000Z",
"endsAt": "2026-11-12T09:00:00.000Z",
"serviceName": "Signature facial",
"professionalName": "Thandi N.",
"salonName": "Salon Le Fresh",
"timeZone": "Africa/Johannesburg",
"cancellationNoticeHours": 24
}Errors
Every error has the same shape: { "ok": false, "code": "…", "error": "plain words" }. Show error to people; branch on code.
error says what.Retry-After seconds.Rate limits
- Per key: 300 free-time calls a minute, and 60 bookings every 10 minutes.
- Without a key (the booking page and embed): 120 free-time calls a minute and 6 bookings every 10 minutes, per visitor.
- Over the limit you get
429 rate_limitedwith aRetry-Afterheader in seconds.
Webhooks
The owner adds an https endpoint in Online Booking › Settings › For developers, chooses its events, and gets a signing secret (shown once). We send a POST with a JSON body for each event.
Events
booking.created: A booking was made: online (the booking page, the embed or the API) or in the Scheduler.booking.moved: A booking's time changed in the Scheduler.booking.cancelled: A booking was cancelled (on time or late).
Body
evt_…, the same on every retry: use it to ignore a repeat.Booking
WF-7K3QX2.confirmed, cancelled, late_cancel and the Scheduler's other states.{
"id": "evt_3f0c9d6a1b2e4f5a8c7d6e5f4a3b2c1d",
"type": "booking.created",
"createdAt": "2026-11-01T09:14:03.000Z",
"test": false,
"salon": {
"slug": "salon-le-fresh"
},
"data": {
"booking": {
"id": "7a3c1e2b-9d4f-4a6b-8c5d-2e1f0a9b8c7d",
"reference": "WF-7A3C1E",
"status": "confirmed",
"startsAt": "2026-11-12T08:00:00.000Z",
"endsAt": "2026-11-12T09:00:00.000Z",
"locationId": "c2d3e4f5-a6b7-4c8d-9e0f-1a2b3c4d5e6f",
"source": "online",
"services": [
{
"serviceId": "5b1d0c3e-2f4a-4d0b-9a51-0c9e7f3a1b22",
"serviceName": "Signature facial",
"professionalId": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"startsAt": "2026-11-12T08:00:00.000Z",
"endsAt": "2026-11-12T09:00:00.000Z"
}
],
"client": {
"id": "9e8d7c6b-5a49-4382-9170-6f5e4d3c2b1a",
"firstName": "Lerato",
"lastName": "Mokoena"
}
}
}
}Signature
Each delivery carries WF-Signature, WF-Event and WF-Event-Id. The signature is t= (unix seconds) and v1=, the hex HMAC-SHA256 of <t>.<raw body> with your secret. Refuse anything older than 5 minutes.
WF-Signature: t=1793524443,v1=5f2b…
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (before JSON.parse).
export function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return expected.length === parts.v1?.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}Delivery
- Answer with any 2xx within 8 seconds. Do slow work afterwards.
- No answer, a timeout, 408, 429 or 5xx: we try again after 2 and 10 seconds. Other answers and redirects are not retried.
- The same event can arrive twice; use its
idto ignore a repeat. Events may arrive out of order;createdAttells you which came first. - A booking never waits for your endpoint. The owner sees each endpoint's last delivery, and can send a test event with a made-up booking (
test: true). - Endpoints must be public https addresses; private networks are never called.
Embed script
One line puts the salon's booking page on any website. No key needed.
<script src="https://wellnessfrontier.vip/embed.js" data-salon="salon-le-fresh" async></script>
inline puts the page into your site; otherwise a floating Book now key opens it over your site.inline: a CSS selector for where the page goes. Without it, right where the tag sits.inline: the starting height in pixels; the page then sizes itself.data-color works too).left puts the floating key bottom left; bottom right by default.When a client books, your page gets a wellnessfrontier:booked event on document, with detail: { salon, reference }.
document.addEventListener("wellnessfrontier:booked", (event) => {
console.log("Booked", event.detail.reference);
});Help
- Where Book now works without code: Works with.
- Something not right? Quote the
WF-reference or the time of the call, and ask the salon's owner to contact us through AIVA in their Wellness Frontier. - Changes to this API are added alongside what is here; nothing you rely on is removed without notice to every salon with a key.