Developers / Storefront API
Storefront API reference
On this page
Your own website, booked through Besmi. A studio on the Icon plan can run a custom site (its own design, its own domain) that shows its artists, prices and open times and takes bookings, deposits, forms and gift cards, while Besmi does everything behind it: the calendar, reminders, intake forms, payments, the client record, the owner's notifications.
Base URL: https://besmi.com/api/storefront/v1
The site books through the same code as the Besmi booking page. Every rule the booking page has (notice and booking window, book release, buffers, the artist offers the service, add-on and variant prices set on the server, client fields and questions, blocklist, early access, discount limits, deposits for new clients only, card on file, intake forms, notifications) applies the same way. The API never has a looser path.
This is not the owner's developer API (/api/v1, secret bsk_live_ keys, full read and write of the studio). That one belongs on a server. This one is safe in a web page.
Every endpoint
All under https://besmi.com/api/storefront/v1, all with the Besmi-Key header. Limits are per visitor (key + IP); see Errors for the numbers.
| Method and path | What it does | Limit |
|---|---|---|
GET /studio | name, brand, bio, SEO, hours, address, contact, policies, booking rules, gift card shop | read |
GET /artists, GET /artists/{id or slug} | the team a client can book, one artist with her services | read |
GET /services | the online menu: categories, services, variants, add-ons, per-artist prices | read |
GET /availability | open times for one day or up to 31 days, one service or a visit of several | availability |
GET /reviews | published reviews, paged, with the rating summary | read |
GET /updates, GET /updates/{slug} | Creative posts (offers, events, news, blog), paged, with tags | read |
GET /products | the shop, to show and link to (buying happens on Besmi) | read |
GET /memberships | membership plans on sale, each with its join link | read |
GET /classes, GET /classes/{id} | in-person classes and seats | read |
GET /classes/inquiry | the months a "send a request" class offers | read |
GET /academy, GET /academy/programs/{id or slug} | online education: programs, options, prices, rounds, seats, checkout links | read |
POST /verification/email | send her a 6-digit code | check |
POST /discounts/check, POST /gift-cards/check | check a code before booking | check |
POST /clients/session, GET /clients/me | a returning client: her token, her details, her upcoming visits | check / read |
POST /bookings | book one service or a visit of several | book |
POST /bookings/finalize | finish a booking after Stripe.js took the deposit | finalize |
POST /bookings/release | let go of an unpaid visit's 15-minute hold | book |
POST /bookings/card, POST /bookings/card/confirm | save a card on file after booking | book |
POST /waitlist | join the appointment waitlist | book |
POST /classes/{id}/purchase, POST /classes/purchases/{id}/confirm | buy a class seat | book |
POST /classes/{id}/waitlist | a spot when a class is full | book |
POST /classes/inquiry | ask about a "send a request" class | book |
POST /academy/waitlist | join an academy program's waitlist while it is closed | book |
Every route also answers OPTIONS (the CORS preflight).
What happens on Besmi, by design. The API never takes a payment for products, gift cards, memberships or academy programs, and never moves or cancels an appointment. For those, link to her Besmi page (each answer gives you the link):
| She wants to | Link to |
|---|---|
| move or cancel a visit | the appointment's manageUrl, or studio.booking.appointmentsUrl for all of hers |
| buy a gift card | studio.giftShop.url (only while giftShop.open) |
| buy a product | each product's url, or shopUrl from /products |
| join a membership | each plan's joinUrl from /memberships |
| buy an academy program | each option's checkoutUrl from /academy |
| fill in an intake form | nothing to do: Besmi emails and texts it after she books |
| contact the studio | studio.contact (email, phone, Instagram). There is no contact form endpoint. |
Build a full site, start to finish
- Get a key. The owner makes a test key in Settings, Your own website, listing
http://localhost:<port>for building (or asks her AI connector, which hasmanage_website_keys). Before launch she makes a live key listing her realhttps://website(s). - The studio.
GET /studiogives the name, logo, colors and fonts (brand),bioText,seofor your<title>and description,hours,addressorlocations,contact,policiesand every booking rule. Showpolicies.textandpolicies.fees.linesnear the booking button, word for word. - The menu and team.
GET /services(group bycategories, showimageUrl,variants,addons) andGET /artists(skip the artist step when it answers[]: a solo studio). - Reviews and posts.
GET /reviews?limit=6for a proof bar,GET /updatesfor offers, events and the blog. Give each post its own page fromGET /updates/{slug}(blog posts carrybodyHtml). - Open times.
GET /availability?serviceId=...&from=...&to=...for a two-week strip, shown in the answer'stimezone. - Booking.
POST /verification/email, thenPOST /bookings. Handlebooked,deposit_required(Stripe.js, then/bookings/finalize),cardRequired, andstatus: "pending"(requests she approves). See Book. - Returning clients and My Appointments.
POST /clients/sessiononce, keep the token, thenGET /clients/mefor a "My appointments" page that listsupcoming, each with itsmanageUrl. - Everything else, only while it is on. The waitlist when
booking.waitlist.enabled, classes whenstudio.classes, gift cards whengiftShop.open, a shop when/productssaysshopEnabled, memberships when/membershipssaysopen, education when/academyanswers 200. Hide each section otherwise: the studio switches these herself. - Keep it fresh. See "Knowing when something changed".
- Go live with the checklist at the end of this page.
Keys and websites
The owner (or an Admin) makes a key in Settings, Your own website and lists the websites it works on. Her AI connector can do the same (get_website_api shows her keys, manage_website_keys makes or changes one), so a designer working with the owner never needs dashboard access. Ask the owner for the key: it is publishable, so sending it in a message is fine.
| Key | Looks like | Works from |
|---|---|---|
| Live | bpk_live_ + 32 characters | the https:// websites listed under it |
| Test | bpk_dev_ + 32 characters | those, plus http://localhost:<port> / 127.0.0.1 for building the site |
Both book real appointments. "Test" only means it also works from a laptop.
- Publishable. The key goes in your site's JavaScript. Anyone can read it, and that's fine: it can only do what the public booking page does, for this one studio, from the websites listed.
- Websites are exact origins.
https://devoelashes.comandhttps://www.devoelashes.comare two entries. No paths, no wildcards, no plainhttp(except localhost on a test key). - New key (rotate): a new key with the same websites. The old one keeps working for 24 hours (0 to 72 through the API) so a live site can switch with no gap. Turn off stops a key at once. A studio can have up to 10 keys on at a time.
- Never put a secret key on a website. The Storefront API refuses
bsk_,sk_andrk_keys and anyAuthorizationheader outright (400).
Send the key in the Besmi-Key header (or ?key= in the URL):
const API = "https://besmi.com/api/storefront/v1";
const KEY = "bpk_live_...";
async function besmi(path, body) {
const res = await fetch(API + path, {
method: body ? "POST" : "GET",
headers: { "Besmi-Key": KEY, ...(body && { "Content-Type": "application/json" }) },
body: body && JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, ...data });
return data;
}
CORS
A browser request is answered with Access-Control-Allow-Origin: <your origin> only when that origin is on the key's list. Never *. Preflights (OPTIONS) are answered for any origin some active key lists; the real request is then checked against its own key. A request with no Origin (your server calling) is allowed: it can't do anything the public booking page can't.
Errors
Every error is JSON: { "error": "<a sentence you can show her>", "code": "..." }, sometimes with field or questionId naming what to fix.
| Status | Code | Meaning |
|---|---|---|
| 400 | SECRET_KEY_REFUSED, AUTHORIZATION_NOT_ACCEPTED | wrong kind of credential |
| 400 | VERIFICATION_REQUIRED, CONSENT_REQUIRED, MISSING_*, INVALID_PHONE, BAD_*, EMAIL_MISMATCH | fix the request; field names the input (client.phone, client.firstName, serviceId, ...) |
| 400 | (from the booking page) with field / questionId | a required field or question |
| 401 | MISSING_KEY, INVALID_KEY | no key, or it's off or rotated out |
| 401 | CLIENT_TOKEN_INVALID | GET /clients/me: the client token is missing, tampered, expired or another studio's |
| 402 | PLAN_REQUIRED | the studio isn't on Icon |
| 403 | STUDIO_NOT_PUBLIC | the studio isn't taking bookings yet (its page isn't public) |
| 403 | ORIGIN_NOT_ALLOWED | this website isn't on the key's list (no CORS headers: the browser shows a CORS error) |
| 403 | blocked: true | this client can't book online |
| 403 | fillWindow: {...} | the service (or option) is booked past her fill window, or she has no visit on file; see Fill windows |
| 404 | STUDIO_UNAVAILABLE, ARTIST_NOT_FOUND, SERVICE_NOT_FOUND, ... | |
| 401 | VERIFICATION_FAILED | the email code (or phone sign-in) didn't check out |
| 402 | PAYMENT_FAILED | a class seat's payment didn't go through |
| 400 | BAD_ITEMS, BAD_ITEM_COUNT, DUPLICATE_SERVICE | a visit's items (see Several services); field names the item, e.g. items[1].variant |
| 400 | DISCOUNT_SINGLE_SERVICE_ONLY | a discount code sent with items (the booking page has none for a visit either) |
| 400 | PAST, LEAD_TIME, TOO_FAR, NOT_RELEASED | the time is outside the studio's notice / booking window / book release |
| 400 | BAD_PREFS, BAD_DATES, NOTE_TOO_LONG, WAITLIST_SINGLE_SERVICE_ONLY | a waitlist request (see Waitlist) |
| 401 | INVALID_HOLD_TOKEN | the holdToken isn't this visit's (or this studio's) |
| 402 | DEPOSIT_NOT_PAID | finalize for a visit whose deposit wasn't paid |
| 403 | WAITLIST_OFF | the studio's waitlist is off (or not on its plan) |
| 409 | SLOT_TAKEN for a visit (one service: SLOT_UNAVAILABLE or no code) | the time was just taken: pick another. Treat any 409 from POST /bookings this way. |
| 409 | EXPIRED | a visit's 15-minute hold ran out before the deposit landed |
| 502 | DEPOSIT_SETUP_FAILED | a visit owed a deposit and the payment couldn't be set up; nothing is booked (the hold is let go), try again |
| 409 | CLASS_FULL (with waitlist), CLASS_STARTED, CLASS_CLOSED, SEATS_OPEN | classes: full, begun, not selling, or a seat is open (buy it) |
| 404 | ACADEMY_NOT_FOUND, PROGRAM_NOT_FOUND, NO_WAITLIST; 409 PROGRAM_OPEN; 400 BAD_WAITLIST | the Academy (see Academy) |
| 304 | (no body) | a GET sent with If-None-Match and nothing changed (see Knowing when something changed) |
| 429 | RATE_LIMITED | slow down; see Retry-After |
| 500 | SERVER_ERROR | something failed on Besmi's side; try again |
Rate limits are per visitor (key + IP): 240 reads a minute, 90 availability calls a minute, 30 code / gift / discount checks per 10 minutes, 20 booking calls an hour (bookings, waitlists, release, class seats and requests, card on file), 60 finalize calls per 10 minutes. There is no studio-wide ceiling (one shared counter would let anyone switch a studio's bookings off). The booking page's own limits apply underneath (3 email codes per address per hour, and so on).
Knowing when something changed
The studio edits her menu, prices, hours, posts and classes in Besmi, and your site isn't told directly. Two ways to stay current:
- Read in the browser. Load
/studio,/servicesand the rest on each page view (answers areno-store, so always current). The live sites built on this API do this, with a build-time copy as the first paint and for search engines. - Rebuild when it changed. Every successful
GETcarries anETag, a fingerprint of exactly what it answered. Keep it and send it back asIf-None-Match: the answer is304 Not Modifiedwith no body when nothing changed, or200with the new content (and a newETag) when something did. A scheduled job (GitHub Actions on a cron, say every 15 minutes) can check the endpoints your pages are built from and rebuild only on a change.
// check-besmi.mjs: exits 1 when anything changed since the last run
import fs from "node:fs";
const API = "https://besmi.com/api/storefront/v1", KEY = process.env.BESMI_KEY;
const seen = fs.existsSync("etags.json") ? JSON.parse(fs.readFileSync("etags.json", "utf8")) : {};
let changed = false;
for (const path of ["/studio", "/services", "/artists", "/updates?limit=50", "/classes"]) {
const res = await fetch(API + path, { headers: { "Besmi-Key": KEY, ...(seen[path] && { "If-None-Match": seen[path] }) } });
if (res.status === 200) { changed = true; seen[path] = res.headers.get("etag"); }
}
fs.writeFileSync("etags.json", JSON.stringify(seen));
process.exit(changed ? 1 : 0);
The ETag covers the whole answer, so any change in what the endpoint returns (a price, a seat sold, a new review) counts. A request with If-None-Match still counts against the rate limit. In a browser, ETag is readable (Access-Control-Expose-Headers) and If-None-Match is an allowed request header. Besmi doesn't call your site when something changes (there are no webhooks to websites yet).
Read
GET /studio
Name, brand, logo, hours, address, public contact, policies, team levels, and what the booking form asks. The whole answer, for a single-location studio that shows its full address:
{ "studio": {
"id": "biz123", "name": "De Voe Lashes", "slug": "devoe", "bio": "<p>Lash studio in Denver.</p>",
"bioText": "Lash studio in Denver.",
"seo": { "title": "De Voe Lashes: Lash Extensions in Denver, CO | Book Online",
"description": "Lash Extensions in Denver, CO. Lash studio in Denver.",
"image": "https://besmi.com/api/og?slug=devoe&v=1794158067780", "category": "Lash Extensions" },
"logoUrl": "https://.../logo.png",
"photoUrl": "https://.../avatar.jpg",
"bookingPageUrl": "https://besmi.com/devoe/book",
"timezone": "America/Denver", "currency": "USD", "country": "US",
"city": "Denver",
"address": { "line1": "123 Main St", "line2": "Suite 4", "city": "Denver", "region": "CO",
"postalCode": "80202", "formatted": "123 Main St, Suite 4, Denver, CO 80202" },
"locations": [],
"contact": { "email": "hello@devoelashes.com", "phone": "+13035550100",
"instagram": "devoelashes", "website": "https://devoelashes.com" },
"brand": { "colors": { "primary": "#6B1E3A", "secondary": "#D9B8A0", "background": "#FBF7F4", "text": "#2A1A1F" },
"font": "Playfair Display", "bodyFont": "Inter", "shape": "rounded" },
"hours": {
"sunday": { "open": false, "start": null, "end": null },
"monday": { "open": false, "start": null, "end": null },
"tuesday": { "open": true, "start": "09:00", "end": "18:00" },
"wednesday": { "open": true, "start": "09:00", "end": "18:00" },
"thursday": { "open": true, "start": "10:00", "end": "19:00" },
"friday": { "open": true, "start": "09:00", "end": "17:00" },
"saturday": { "open": true, "start": "09:00", "end": "15:00" }
},
"booking": {
"requiresApproval": false,
"cardOnFile": false,
"minNoticeHours": 12,
"maxDaysAhead": 60,
"deposit": { "type": "flat", "amount": 25, "appliesTo": "new_clients", "per": "service" },
"giftCards": true,
"verification": ["email"],
"clientFields": { "firstName": "required", "email": "required", "phone": "required",
"lastName": "required", "instagram": "optional", "birthday": "off",
"note": "optional", "referral": "optional" },
"referralOptions": ["Instagram", "TikTok", "Google", "A friend", "Walked by", "Other"],
"questions": [
{ "id": "q_k2j4h5g6f7", "label": "Is this your first lash set?", "type": "yes_no",
"required": true, "frequency": "once" },
{ "id": "q_a1b2c3d4e5", "label": "Which look do you want?", "help": "Pick the closest.",
"type": "single", "options": ["Natural", "Wispy", "Volume"], "required": false,
"serviceIds": ["s1"], "frequency": "every",
"warn": { "answers": ["Volume"], "message": "Volume needs a 2-hour slot.", "block": false } }
],
"waitlist": { "enabled": true, "horizonDays": 14 },
"processingFee": { "percent": 2.9, "fixedCents": 30, "label": "Card processing fee" },
"appointmentsUrl": "https://besmi.com/devoe/appointments",
"datePicker": "list"
},
"policies": {
"text": ["A $25 deposit holds your time.", "Cancel 48 hours ahead or the deposit is kept."],
"showOnPage": true,
"cancellationWindowHours": 48,
"fees": {
"windowHours": 48,
"lateCancel": { "type": "flat", "amount": 40, "percent": null },
"noShow": { "type": "percent", "percent": 100, "amount": null },
"lines": ["Cancel within 48 hours of an appointment: $40.",
"Miss an appointment: 100% of the appointment's price."]
}
},
"team": { "artistCount": 3,
"levels": [{ "id": "junior", "name": "Junior", "order": 0, "description": null }],
"helpMeChoose": false },
"classes": true,
"giftShop": { "open": true, "url": "https://besmi.com/devoelashes/gift",
"amounts": [75, 100, 150, 200], "custom": { "min": 25, "max": 500 },
"serviceGifts": true, "promo": null }
} }
| Field | |
|---|---|
bio, bioText | bio is her bio as she wrote it, rich text (it may carry <p>, <strong>, links): sanitize before setting it as HTML. bioText is the same words as plain text, for most uses. |
seo | what her Besmi page puts in <title> and its description, built from her name, what she does (category, from her menu) and her city: a starting point for your own. image is her share card (1200 x 630) for og:image. |
logoUrl, photoUrl | top level; null when not set. photoUrl is the studio's profile picture. |
city | null when the studio hides its location |
address | an object, or null unless the studio shows its full address. formatted is the one-line version for display and map links. Fields inside are null when empty. |
locations | [] for a single-location studio (use address). Multi-location studios: [{ id, name, address, note, timezone }], where address here is a string, note is null or parking / entry help, and timezone is the place's clock (its own when set in Settings, else the studio's). |
contact.* | each null when not set |
brand.colors | { primary, secondary, background, text } or null; font / bodyFont font names or null; shape is rounded, square, pill, soft, round, leaf or null |
hours | keyed sunday to saturday; each { open, start, end }, times "HH:mm" in timezone, null on a closed day. end is "24:00" when the studio closes at midnight (the end of that day). Special hours and time off are not here: /availability has them. |
booking.requiresApproval | true: new bookings come back status: "pending" until the studio confirms |
booking.cardOnFile | true: a booking may answer cardRequired: true (see Card on file) |
booking.deposit | null when no deposit; else type "flat" (dollars) or "percent", amount, appliesTo "everyone" or "new_clients", and per: for a visit of several services, "service" charges a flat deposit on each service, "booking" once for the visit (never more than the visit costs). A percent deposit is always "service". Whether a given client owes it is decided at booking time; the booking response's deposit.amount is what is charged. |
booking.giftCards | gift cards can be used when booking |
booking.verification | ["email"]: how a website proves the client (the email code) |
booking.clientFields | each field "off", "optional" or "required". firstName, email and phone are always "required". The studio sets the rest (lastName, instagram, birthday, note, referral). An "off" field sent anyway is dropped. |
booking.referralOptions | the choices for referral ("How did you hear about us?") |
booking.questions | the studio's own questions (Icon). See below. |
booking.waitlist | { enabled, horizonDays }. enabled is true exactly when the booking page offers "Join the waitlist" (Icon and the studio's switch); horizonDays (7, 14 or 30) is how far ahead a request can look. See Waitlist. |
booking.processingFee | null, or what a card deposit adds when the studio passes the card fee on: { percent, fixedCents, label }. For a deposit of c cents the fee is round((c * p + fixedCents) / (1 - p)) cents with p = percent / 100 (Besmi's own math; the deposit answer carries the real number as processingFee). Show label next to it. |
booking.appointmentsUrl | her own page on Besmi to see, move or cancel what she booked ("Already booked? Manage or reschedule" on the booking page) |
booking.datePicker | how the studio set its booking page to show days: "list" (a row of days) or "calendar" (a month) |
policies.text | the studio's booking policies, a list of paragraphs (empty lines dropped), exactly what the booking page shows and asks her to agree to before she books. [] when she wrote none. |
policies.showOnPage | the studio's "Show policies on your page" switch. It only places a policies section on her own Besmi page; the booking page shows text either way. A site that wants to mirror that section can read it. |
policies.cancellationWindowHours | hours before a visit she can still cancel, or null |
policies.fees | the late-cancel and no-show fees in numbers: windowHours (24 when the studio set none), lateCancel / noShow each null (no fee) or { type: "flat", amount } (dollars) / { type: "percent", percent } (of the booked price). A paid deposit counts toward a fee. lines: the sentences the booking page shows before a booking is priced, ready to display. |
team.levels | [{ id, name, order, description }], lowest order first; [] without levels |
classes | true when it has published classes |
giftShop | her online gift card shop. open is true exactly when her gift card page (url) sells: her plan has gift cards, her switch is on and card payments are set up. Closed, the rest is empty (url: null, amounts: []). amounts are her preset values (whole currency units, at most her online maximum); custom is { min, max } when buyers can type their own amount, else null; serviceGifts means a service can be given as the gift; promo is the bonus offer selling right now ("Buy $100, get a $20 bonus card") or null. Link buyers to url: the purchase happens on her Besmi page. |
Questions. Each is { id, label, help?, type, options?, required, serviceIds?, frequency, warn? }:
type | yes_no (answer "Yes" / "No"), single (one of options), multi (a list of options), short (text up to 500), paragraph (text up to 2,000), number, date ("YYYY-MM-DD"), photo (always optional; a website can't upload one yet, so skip it) |
serviceIds | ask it only for these services; absent = every service |
frequency | "once": asked until she has answered it, then kept on her profile; a returning client who already answered may leave it out (Besmi checks). "every": each booking. |
warn | { answers, message, block }: show message when her answer is one of answers; block: true means she can't book with that answer |
Send answers in POST /bookings as answers: { [id]: value }.
GET /artists[?serviceId=]
Everyone a client can book online (the owner too, when she's bookable). A solo studio answers []: skip the artist step and book the studio.
{ "artists": [{
"id": "uid", "slug": "sydney", "name": "Sydney", "photoUrl": "...",
"title": "Volume artist", "bio": "...", "level": { "id": "senior", "name": "Senior", "order": 1 },
"specialties": ["Volume", "Wispy"], "yearsExperience": 5, "instagram": "sydlashes",
"gallery": [{ "url": "...", "caption": "Wispy set", "serviceId": null }],
"serviceIds": null, "bookingUrl": "https://besmi.com/devoe/book?staffId=uid",
"rating": { "average": 4.9, "count": 31 }, "fromPrice": 60,
"location": { "id": "den", "name": "Denver studio", "timezone": "America/Denver" }
}] }
serviceIds: null means she does every service. slug, photoUrl, title, bio, level, yearsExperience, instagram, rating and fromPrice may be null. location is where she works ("Works at" on her card in Team): her bookings happen there and her times are on its clock. null means wherever the studio is that day, on the studio's clock. Senior levels come first. Never in the answer: her email, phone, role, pay, or anything about clients.
GET /artists/{id or slug}
{ artist, services }: her profile (as above) and the services she does, each shaped like /services below at her price and length, without the artists list.
GET /services[?artistId=]
The online menu.
{ "categories": [{ "id": "c1", "name": "Full sets", "order": 0 }],
"services": [{
"id": "s1", "name": "Classic Full Set", "description": "Natural, one extension per lash.",
"category": { "id": "c1", "name": "Full sets" }, "categoryId": "c1",
"price": 100, "priceFrom": false, "durationMinutes": 120, "imageUrl": "https://.../classic.jpg",
"variants": [{ "name": "Hybrid", "price": 120, "durationMinutes": 135, "fillWindowDays": null }],
"addons": [{ "id": "a1", "name": "Lash bath", "price": 15, "durationMinutes": 15 }],
"offeredDays": null, "fillWindowDays": null, "standalone": false,
"artists": [{ "artistId": "uid", "price": 130, "durationMinutes": 120 }]
}] }
| Field | |
|---|---|
category | { id, name }, or null when the service has no category |
categoryId | the same id as category.id (or null), for filtering |
priceFrom | true for a starting price: show price, every variant's and every artist's price as "$85+" (the booking page adds "Final price is confirmed at your appointment." under the total). The final amount is set at checkout; deposits still use the number. false = a fixed price |
imageUrl | null when the service has no photo |
variants | options she picks one of; send the name as variant when booking |
addons | what can be added to this service; send ids as addonIds |
offeredDays | weekdays it can be booked, 0 = Sunday; null = every open day |
fillWindowDays | bookable only within this many days of the client's last visit (on the service, and on each variant, which can differ); null = no window. Show it the way the booking page does: "Within 21 days of your last visit" |
standalone | an add-on the studio lets clients book on its own (no category, no add-ons) |
artists | per artist who does it: her own price if she has one, else her level's price, else the service price; length from her level, else the service. [] for a solo studio. |
With artistId, only her services, and price / durationMinutes are hers. categories are sorted by order.
GET /availability
The booking page's own engine: hours, special hours, breaks, time off, existing bookings, buffers, notice, booking window, book release.
| Param | |
|---|---|
serviceId | required |
date=YYYY-MM-DD | one day: { slots: [{ start, end }] } |
from, to | up to 31 days: { days: { "YYYY-MM-DD": [{ start, end }] } } |
artistId | her id or slug; omit or any for anyone who does the service |
variant | a variant's name |
addonIds | comma separated |
withArtists=1 | one day, any artist: each slot gets artistIds, who is free then |
locationId | any artist at this place: the artists who work there, plus those with no place of their own |
items | several services in one visit, instead of serviceId / variant / addonIds: a URL-encoded JSON array of { serviceId, variant?, addonIds? }, 2 to 5, each service once. See below. |
Times are ISO instants (UTC). Show them in the answer's timezone: the artist's location's clock when she works at a place in another time zone, the picked place's for locationId, else studio.timezone.
With any artist, each artist is asked about the time SHE takes: her level's length when the service has level times (plus the add-ons), not the service's own. A time is offered when at least one artist who does the service can fit hers, and withArtists=1 names only those. (Before Oct 2026 every artist was sized at the service's length, so a junior whose set runs longer was offered times she couldn't finish.) With a variant, its length is everyone's.
const q = new URLSearchParams({ serviceId: "s1", artistId: "sydney", from: "2026-10-20", to: "2026-11-02" });
const { days } = await besmi("/availability?" + q);
Several services in one visit. One artist does them back to back, so the times are for the whole visit, worked out like the booking page's cart:
- its length is each service's (the option's, or the artist's own length for the service; the menu's for any artist) plus each line's add-ons, up to 8 hours;
- the gap kept after it is the largest of the services' buffers;
- it can only be on a weekday every service is offered (no shared weekday: no times);
- "any artist" is the artists who do every service in it. A named artist who doesn't do one of them is
400 ARTIST_DOESNT_OFFER.
const items = [{ serviceId: "s1", addonIds: ["a1"] }, { serviceId: "s2", variant: "Long" }];
const q = new URLSearchParams({ items: JSON.stringify(items), date: "2026-10-21" });
const r = await besmi("/availability?" + q);
{ "timezone": "America/Chicago", "serviceId": null,
"items": [{ "serviceId": "s1", "variant": null, "addonIds": ["a1"], "durationMinutes": 75, "price": 110 },
{ "serviceId": "s2", "variant": "Long", "addonIds": [], "durationMinutes": 45, "price": 70 }],
"price": 180, "artistId": null, "durationMinutes": 120,
"date": "2026-10-21", "slots": [{ "start": "2026-10-21T14:00:00.000Z", "end": "2026-10-21T16:00:00.000Z" }] }
durationMinutes is the whole visit and each slot's end is when it ends. items[].price and price are the menu (or the named artist's) prices for showing a total; the booking works its own prices out. from / to, artistId, withArtists=1 and locationId work as for one service. Errors: BAD_ITEMS (not a JSON array, or sent with serviceId), BAD_ITEM_COUNT, DUPLICATE_SERVICE, MISSING_SERVICE, SERVICE_NOT_FOUND (404), BAD_VARIANT, BAD_ADDON, BAD_DURATION, each with field naming the item.
GET /reviews[?artistId=][&limit=10][&cursor=]
Published reviews, newest first: first name and last initial, the service, approved photos, the studio's reply. With artistId, hers (reviews of her appointments; no paging).
{ "reviews": [{
"id": "apt123", "rating": 5, "title": "Best set ever", "body": "Sydney was so gentle...",
"photos": ["https://.../review.jpg"], "author": "Ana D.", "serviceName": "Classic Full Set",
"artistId": "uid", "reply": "Thank you, Ana!", "createdAt": "2026-10-02T18:20:00.000Z"
}],
"nextCursor": "apt122",
"summary": { "average": 4.9, "count": 87 } }
| Field | |
|---|---|
author | first name and last initial ("Ana D."), or just the first name |
title, reply, artistId | null when there is none |
photos | URLs of approved photos only |
serviceName | the service as it was named when reviewed (may be "") |
nextCursor | pass as cursor for the next page; null at the end |
summary | the studio's rating (or, with artistId, hers over her recent reviews) |
limit is 1 to 50 (default 10).
GET /updates[?kind=tour,promo][&tag=aftercare][&limit=20][&offset=0]
The studio's posts from Creative (Creative in the menu; also the Creative section of her Besmi page): tour stops, class drops, offers, studio news, and blog posts. Only live posts: published, past their scheduled time, and not past their last day (a tour stop ends after its last day, an offer after its end date, in the studio's time zone). Pinned first, then newest.
{
"feed": { "title": "Latest from De Voe Lashes", "custom": false, "journalTitle": "Journal", "journalCustom": false },
"updates": [{
"id": "...", "slug": "opal-free-in-las-vegas", "kind": "tour",
"title": "Opal Free in Las Vegas", "body": "Three days...", "photoUrl": "https://...",
"pinned": false, "publishedAt": "2026-10-17T22:10:00.000Z", "endsOn": "2026-11-01",
"event": { "startDate": "2026-10-30", "endDate": "2026-11-01", "city": "Las Vegas, NV", "venue": "Opal Free" },
"code": null,
"cta": { "type": "service", "label": "Book a fill", "serviceId": "...", "serviceName": "Lash Fill" },
"updatedAt": "2026-10-18T02:00:00.000Z",
"url": "https://besmi.com/devoe/creative/opal-free-in-las-vegas"
}],
"total": 5,
"offset": 0,
"nextOffset": null,
"tags": [{ "tag": "Aftercare", "count": 3 }, { "tag": "Retention", "count": 1 }]
}
| Field | |
|---|---|
kind | update, tour, class, promo, news, blog |
limit, offset, nextOffset, total | limit 1 to 50 (default 20). Page through every live post with offset; nextOffset is the next page's offset, null on the last page. total counts every post the filters match |
tags | each tag across the posts of this kind filter (before tag), with how many posts carry it, most used first: the blog's categories. Filter with ?tag= |
updatedAt | when the post last changed (never before publishedAt): <lastmod> in a sitemap |
url | its page on her Besmi link (besmi.com/<slug>/creative/<slug>) |
feed.title | what the studio calls the section; custom: false means Besmi's default, which a site may replace with its own |
feed.journalTitle | what the studio calls its blog (journalCustom: false = the default, "Journal") |
feed.cards | her card sizes setting: even (default: cards in a row the same size, text clamped with Read more) or natural (each card as tall as its post, photos in her framed shape) |
tag | filter: posts carrying this tag (any case) |
excerpt, readMinutes, tags, author, seo | blog posts: the summary (hers, or the start of the post), minutes to read, up to 6 tags, the byline (name, photoUrl), and seo.title / seo.description for the page's <title> and description. Other kinds: excerpt = the text, the rest empty |
event | dates (YYYY-MM-DD, the studio's calendar), city, venue; null when not set |
photoFrame | how she framed the photo: x, y (0 to 100, the point kept in view: CSS object-position: x% y%), fit (fill, or whole to show all of it), shape (landscape 4:3, square, portrait 4:5, wide 16:9). null = centered landscape fill |
code | an offer's discount code, when she added one |
cta.type | book (the booking page), service, artist (with artistName, artistSlug), class (classId, classTitle), link (url, https), instagram (her DMs). A button whose service, artist or class is gone is dropped (cta: null); the post stays. |
GET /updates/{slug} returns one live post as { update }, for its own page on the site. A draft, a scheduled post not yet live, an ended post or a deleted one is 404 NOT_FOUND, so an old shared link never shows a stale offer.
A blog post's page also gets bodyHtml: the post, cleaned by Besmi and safe to set as HTML. Headings (h2, h3), paragraphs, lists, quotes, links, <figure> photos with captions, and:
| Markup | |
|---|---|
<figure class="besmi-embed besmi-embed--{shape}" data-provider="..."> | an embed player Besmi built (YouTube, Vimeo, Instagram, TikTok, Spotify, Apple Podcasts, Google Maps). shape: video and map (16:9 / 4:3), tall (9:16), post (an Instagram post, about 560px tall), audio (160px), audio-lg (352px). Size the iframe by it |
<div class="besmi-html"> | an HTML block from the studio's web designer: no scripts, styles, forms or outside frames; its classes are kept for the site to style |
The list never carries bodyHtml, to stay light.
GET /products
The studio's live shop: exactly what besmi.com/<slug>/shop shows, from the same loader. Products she adds, edits, reorders, hides or removes, and collections she makes, hides or rearranges, show on the next request (answers are no-store, like every route here). Buying happens on Besmi: link each product to its url.
{
"shopEnabled": true,
"currency": "USD",
"shopUrl": "https://besmi.com/devoelashes/shop",
"collections": [{ "id": "c1", "name": "Aftercare", "productIds": ["p2", "p1"] }],
"products": [{
"id": "p2", "name": "Lash Cleanser", "short": "Gentle foam", "description": "...",
"brand": "", "size": "50 ml",
"priceCents": 1800, "priceRange": null, "compareAtCents": 2200,
"images": ["https://.../cleanser.jpg"],
"variants": [], "options": null,
"inStock": true, "left": 2, "badge": "Best seller",
"collectionIds": ["c1"],
"url": "https://besmi.com/devoelashes/shop/p2"
}]
}
| Field | |
|---|---|
| money | cents, in currency (the shop's unit; /services is in dollars) |
products | her order, sold-out ones last. Only products on her shop: on, retail, not kept off the shop, priced. Drafts, hidden and professional-only products never appear |
priceCents | the lowest price (the cheapest option's, with variants); priceRange [low, high] when options differ, else null |
compareAtCents | the "was" price when on sale, else null |
variants | options she picks one of (id, name, priceCents, compareAtCents, inStock, left); [] for none. options names the rows ("Color", "Curl") when every variant reads "Black / CC", else null |
inStock, left | left only when 3 or fewer remain (as the shop shows "2 left"); never the stock count |
badge | "Best seller", "24 sold", or null |
collections | her visible collections in her order (or her product categories until she makes one), each with its products in display order. Empty ones are left out |
shopEnabled: false | the shop isn't selling (turned off, payments not set up, no pickup or shipping): shopUrl is null and both lists are empty |
GET /memberships
Her membership plans on sale, exactly the listed plans besmi.com/<slug>/memberships shows. Joining and paying happen there: link each plan to its joinUrl.
{ "open": true, "url": "https://besmi.com/devoelashes/memberships",
"plans": [{
"id": "pl1", "name": "Lash Club", "description": "A fill every 3 weeks.",
"priceCents": 9900, "currency": "usd", "cadence": { "unit": "month", "count": 1 }, "cadenceText": "monthly",
"paymentsPerYear": 12, "benefits": ["One Lash Fill each month", "10% off add-ons"],
"valueCents": 12000, "minimumCycles": 3,
"disclosure": ["Renews every month until you cancel."], "terms": "",
"joinUrl": "https://besmi.com/devoelashes/memberships?plan=pl1"
}] }
| Field | |
|---|---|
open | false (with url: null, plans: []) when nothing is on sale: memberships off, not on her plan, or payments not set up |
priceCents, valueCents | cents. valueCents is what the included services cost each cycle without the plan (her menu prices) |
benefits, disclosure | ready-made lines. Show disclosure near the join button, as her page does |
minimumCycles | payments she commits to before she can cancel (0 or 1 = none) |
Plans she shares only by link stay off this list.
Academy (online education)
For a studio that sells online programs (courses, mentorships) in Besmi's Academy, the education side Ready Set Grow (besmi.com/readysetgrow) is built on. Not the same as Classes (in-person seats, below). Everything answers 404 ACADEMY_NOT_FOUND until the studio has a published academy.
GET /academy | her academy, every program on sale, and programs coming soon that take a waitlist |
GET /academy/programs/{id or slug} | { program } on sale, or { comingSoon }; else 404 PROGRAM_NOT_FOUND |
POST /academy/waitlist | { program, firstName, email, instagram?, answers?, src? } while a program is closed |
A program on sale (the fields her own sales page uses; trimmed here):
{
id, slug, title, subtitle, description /* safe HTML */, coverUrl, trailer,
url: "https://besmi.com/readysetglam/academy/p/ready-set-grow",
cohort: true, // runs in rounds
rounds: [{ id, name: "Round 1", startsAt, left: 4, open: true, checkoutQuery: "round=r1" }],
tiers: [{ id, name: "Core", priceCents: 19900, nowCents: 19900, depositCents, installments,
early: { priceCents: 14900, endsAt } | null, bullets: ["..."], available: true, left: 6,
billing: "once" | "monthly", payLaterWith: ["Klarna"],
checkoutUrl: "https://besmi.com/readysetglam/academy/p/ready-set-grow?tier=t1" }],
canJoin: true, // an option is available (and, for a cohort, a round is open)
modules, faq, reviews, kit, certificate, lessonCount, totalSeconds,
currency: "usd", closedView: "waitlist" | "closed", waitlistInstagram: "off" | "optional" | "required",
// What her waitlist form asks besides name and email, in order. Never a phone number here.
waitlistFields: [{ id: "instagram", kind: "instagram", label: "", required: true },
{ id: "f_ab12cd", kind: "text", label: "How long have you been lashing?", required: false },
{ id: "f_ef34gh", kind: "choice", label: "Which round?", required: true, choices: ["Spring", "Summer"] }]
}
Send a buyer to checkoutUrl, adding & plus the round's checkoutQuery for a cohort (...?tier=t1&round=r1). Checkout, payment plans, codes and the student's account all happen on Besmi.
While nobody can join (canJoin: false, or a program in comingSoon) and closedView is "waitlist", collect names:
await besmi("/academy/waitlist", { program: "ready-set-grow", firstName: "Ana", email: "ana@example.com", instagram: "ana.lashes", src: "site" });
// 201 { result: "waitlisted" }
Answers to her own questions go in answers, by field id: answers: { f_ab12cd: "2 years", f_ef34gh: "Spring" } (up to 200 characters each; a choice answer must be one of its choices). From your site they're optional even when required is true, so a site built before fields existed keeps working; ask them if you can.
She gets "You're on the list" the first time (and the educator an alert); joining again changes nothing. Ask for instagram when waitlistInstagram is "required" (offer it on "optional"). Errors: 409 PROGRAM_OPEN (it's on sale, send her to checkout), 404 NO_WAITLIST, 400 BAD_WAITLIST (her name, email or handle; error says which), 404 ACADEMY_NOT_FOUND.
Book
1. Verify her email
await besmi("/verification/email", { email: "client@example.com" });
// { sent: true, expiresInMinutes: 10 }
// She gets a 6-digit code (10 minutes). Collect it in your form.
(A site that runs Firebase phone sign-in on Besmi's project can send verification: { idToken } instead.)
2. POST /bookings
const r = await besmi("/bookings", {
serviceId: "s1",
artistId: "sydney", // or "any" / omit
startAt: slot.start, // from /availability
variant: "Hybrid", // optional
addonIds: ["a1"], // optional
locationId: "loc1", // optional: multi-location studios; with any artist, the place picked (artists who work there)
client: {
firstName: "Ana", lastName: "Diaz",
email: "client@example.com", phone: "+13035550123",
instagram: "anadiaz", birthday: "09-14", note: "Sensitive eyes", referral: "Instagram",
},
answers: { q1: "No" }, // the studio's questions, by id
consent: { transactional: true, marketing: false }, // transactional is required
verification: { email: "client@example.com", code: "123456" },
discountCode: "WELCOME10", // optional
giftCardCode: "ABCD-EFGH-JKMN-PQRS", // optional
});
Build the form from studio.booking.clientFields. firstName, email and phone are always required: a missing phone answers 400 MISSING_PHONE, and one without an area code (fewer than 10 digits, or more than 15) answers 400 INVALID_PHONE, both with field: "client.phone". Send it with the country code (+13035550123); 10 digits are read as a US/Canada number. Fields the studio switched off are dropped; required ones are checked (a missing one answers 400 with field, a missing question with questionId). The email must be the one the code went to. Prices, lengths and the deposit are always worked out on Besmi's side; nothing price-like is read from the request.
Two answers:
201 { "result": "booked",
"appointment": { "id": "...", "status": "confirmed", "manageUrl": "https://besmi.com/..." },
"cardRequired": false, "giftCardAttached": false }
With any artist, Besmi books a free artist who can fit her own length (the least booked one) and the visit runs that long. When nobody can, the answer is 409 (the time was just taken), never a booking with no artist, which would block every artist's chair. A studio with no bookable artists books its own hours, as before.
A returning client the studio gave a usual length for a service (set on her visit in Besmi: "Remember as her usual") books that length instead of the menu's or her artist's, plus any add-ons. In a visit with several services each service takes her usual (where she has one) and the next one starts right after it. It applies once Besmi matches her by the verified contact, so /availability still sizes times by the normal menu length (it doesn't know who she is yet); the visit just ends sooner and the rest of the time is free for others. A site can't send its own lengths: only the studio sets them.
status is pending when the studio approves bookings. In Besmi the appointment says Booked on devoelashes.com (see "How the studio sees website bookings"). manageUrl is her own link to see, move or cancel it. Besmi texts and emails her, sends any intake form, and tells the studio, exactly as for the booking page.
200 { "result": "deposit_required",
"deposit": { "bookingIntentId": "...", "amount": 25, "processingFee": 0, "currency": "USD",
"clientSecret": "pi_..._secret_...", "paymentIntentId": "pi_...",
"connectedAccountId": "acct_...", "publishableKey": "pk_live_..." } }
Nothing is booked yet. The time is held while she pays.
3. Deposit: Stripe.js, then finalize
The deposit is a PaymentIntent on the studio's own Stripe account, so load Stripe.js with Besmi's publishable key and the studio's account:
<script src="https://js.stripe.com/v3/"></script>
<div id="payment-element"></div>
const stripe = Stripe(r.deposit.publishableKey, { stripeAccount: r.deposit.connectedAccountId });
const elements = stripe.elements({ clientSecret: r.deposit.clientSecret });
elements.create("payment").mount("#payment-element");
// on "Pay"
const { error } = await stripe.confirmPayment({ elements, redirect: "if_required", confirmParams: { return_url: location.href } });
if (error) return show(error.message);
const done = await besmi("/bookings/finalize", { bookingIntentId: r.deposit.bookingIntentId });
// { result: "booked", appointment: { id, status, manageUrl }, cardRequired, giftCardAttached }
finalize checks the payment with Stripe, re-checks the time and books it. It is safe to call again (same appointment), and Besmi's webhook finishes the booking on its own if her connection drops. A 409 means the time was taken while she paid: the deposit goes back to her card automatically (refunded: true); send her back to pick another time. After a bank redirect, call finalize again with the bookingIntentId you kept.
A gift card that covers the deposit books straight away (result: "booked", depositPaidByGiftCard).
4. Card on file (some studios)
When the answer says cardRequired: true:
const c = await besmi("/bookings/card", { appointmentId, email });
// { clientSecret: "seti_..._secret_...", setupIntentId, connectedAccountId, publishableKey } or { alreadySaved: true }
const stripe = Stripe(c.publishableKey, { stripeAccount: c.connectedAccountId });
// mount a Payment Element with c.clientSecret, then stripe.confirmSetup({ elements, redirect: "if_required" })
await besmi("/bookings/card/confirm", { appointmentId, setupIntentId: c.setupIntentId });
// { saved: true }
Several services in one visit
Send items instead of serviceId / variant / addonIds: 2 to 5 { serviceId, variant?, addonIds? }, each service once, in the order she'll have them. Everything else (artistId, startAt from /availability with the same items, locationId, client, answers, consent, verification, giftCardCode) is as for one service. One artist does the whole visit, back to back. This is the booking page's own cart, through the same route.
const r = await besmi("/bookings", {
items: [{ serviceId: "s1", addonIds: ["a1"] }, { serviceId: "s2", variant: "Long" }],
artistId: "any",
startAt: slot.start,
client: { firstName: "Ana", lastName: "Diaz", email: "client@example.com", phone: "+13035550123" },
answers: {},
consent: { transactional: true, marketing: false },
verification: { email: "client@example.com", code: "123456" },
});
Like the booking page's cart: no discount codes (400 DISCOUNT_SINGLE_SERVICE_ONLY, nothing booked: book without it or one service at a time), no waitlist, and a gift card is attached for checkout but never pays the deposit (the deposit is always by card). The studio's questions are asked for every service in the visit. serviceId and items together is 400 BAD_ITEMS.
Booked:
201 { "result": "booked",
"appointment": { "id": "apt1", "ids": ["apt1", "apt2"], "groupId": "9b2f...",
"status": "confirmed", "manageUrl": "https://besmi.com/manage/..." },
"cardRequired": false, "giftCardAttached": false }
One appointment per service (ids, in order; id is the first) sharing groupId. She hears about it once, for the whole visit. cardRequired: true: the card-on-file step below, with appointmentId: appointment.id (one card covers every service in the visit).
A deposit owed: the visit is written now and held 15 minutes while she pays (the booking page does the same). One payment covers every service:
200 { "result": "deposit_required",
"deposit": { "appointmentId": "apt1", "appointmentIds": ["apt1", "apt2"], "groupId": "9b2f...",
"amount": 40, "processingFee": 1.5, "currency": "USD",
"clientSecret": "pi_..._secret_...", "paymentIntentId": "pi_...",
"connectedAccountId": "acct_...", "publishableKey": "pk_live_...",
"holdMinutes": 15, "holdExpiresAt": "2026-10-21T15:15:00.000Z",
"holdToken": "eyJ...", "statusAfterDeposit": "confirmed" },
"giftCardAttached": false }
There is no bookingIntentId: pay with Stripe.js exactly as above, then finalize with the visit's appointmentId and holdToken:
const { error } = await stripe.confirmPayment({ elements, redirect: "if_required", confirmParams: { return_url: location.href } });
if (error) return show(error.message);
let done;
for (let i = 0; i < 10; i++) {
const res = await fetch(API + "/bookings/finalize", { method: "POST",
headers: { "Besmi-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ appointmentId: r.deposit.appointmentId, holdToken: r.deposit.holdToken }) });
done = await res.json();
if (res.status !== 202) break; // 202 = the payment is on its way
await new Promise((ok) => setTimeout(ok, 2000));
}
// 200 { result: "booked", appointment: { id, ids, groupId, status, manageUrl }, cardRequired, giftCardAttached }
Stripe tells Besmi when the deposit lands and every service in the visit is marked paid; finalize reads that:
| Answer | |
|---|---|
200 booked | paid (status is pending when the studio approves bookings; statusAfterDeposit said so up front). manageUrl is set when you send the holdToken. Safe to call again. |
202 { result: "processing", retryAfterSeconds } | Stripe took the payment and Besmi hasn't been told yet: ask again in a couple of seconds (it is usually there within a few). |
402 DEPOSIT_NOT_PAID | no payment went through yet |
409 EXPIRED | the 15-minute hold ran out first and the time was released. Any payment that lands after that is refunded automatically. Send her back to pick a time. |
401 INVALID_HOLD_TOKEN, 404 APPOINTMENT_NOT_FOUND | not this visit |
If the payment can't be set up at all the answer is 502 DEPOSIT_SETUP_FAILED and nothing is held: try again.
Changing her mind on the payment step. The visit holds its time for 15 minutes, so if she goes back to change the time or her details and books again, she'd run into her own hold. Let it go first:
await besmi("/bookings/release", { appointmentId: r.deposit.appointmentId, holdToken: r.deposit.holdToken });
// { result: "released", released: 2 } (0 when there was nothing left to release)
It only lets go of her own services that are still unpaid, and only once the payment can no longer go through: 409 PAID means the deposit already went in and the booking stays (finalize it). 503 RETRY: Stripe couldn't say, try again. A hold you never release ends by itself after 15 minutes.
The holdToken is her own manage link's token (the booking page uses the same proof): only someone who has it can release her visit or get her manageUrl from finalize. Keep it in memory for the payment step; don't put it in a URL, a log or analytics.
Waitlist
When studio.booking.waitlist.enabled is true, a site can offer the booking page's "Join the waitlist" (the page shows it when a day is full or none of the times suit). One service only, never a visit of several. She proves her email with a code first, as for a booking.
const r = await besmi("/waitlist", {
serviceId: "s1",
variant: "Hybrid", // optional
addonIds: ["a1"], // optional
artistId: "sydney", // optional: one artist, or omit / "any"
locationId: "loc1", // optional
prefs: {
mode: "dates", // "any": any day soon, or "dates": specific days
dates: ["2026-10-24", "2026-10-25"], // with "dates": up to 5, from today to today + horizonDays - 1
// withinDays: 14, // with "any": 7, 14 or 30, up to horizonDays (default 14, or 7 when horizonDays is 7)
timeOfDay: "morning", // "any" (default), "morning", "afternoon", "evening"
note: "Saturdays work best", // optional, up to 300 characters
},
client: { firstName: "Ana", lastName: "Diaz", email: "client@example.com", phone: "+13035550123" },
consent: { transactional: true, marketing: false },
verification: { email: "client@example.com", code: "123456" },
});
201 { "result": "waitlisted", "entryId": "wl123", "updated": false,
"wants": "Sat Oct 24, Sun Oct 25, mornings",
"leaveUrl": "https://besmi.com/w/..." }
200 with updated: true means she was already on the list for this service and her choices were updated. Dates are the studio's calendar (studio.timezone). Besmi emails and texts her that she's on it (with leaveUrl, her link to come off it), tells the studio, and offers her an opening when one fits. Her price and length come from the menu, never from the request.
| Error | |
|---|---|
403 WAITLIST_OFF | the studio's waitlist is off |
400 WAITLIST_SINGLE_SERVICE_ONLY | items was sent |
400 BAD_PREFS (field: prefs.mode, prefs.withinDays, prefs.timeOfDay) / BAD_DATES / NOTE_TOO_LONG | her choices |
400 MISSING_SERVICE, SERVICE_NOT_FOUND, BAD_VARIANT, BAD_ADDON, ARTIST_NOT_FOUND, ARTIST_DOESNT_OFFER, LOCATION_NOT_FOUND | what she asked for |
400 VERIFICATION_REQUIRED, CONSENT_REQUIRED, MISSING_FIRST_NAME, MISSING_PHONE, INVALID_PHONE, EMAIL_MISMATCH; 401 VERIFICATION_FAILED | as for a booking |
403 with blocked / earlyAccess / fillWindow | as for a booking |
Fill windows
A studio can let a service (or one option) be booked only within N days of the client's last visit: a 3 week fill within 21 days, say. Her last visit is her latest appointment before the new one (booked or done; cancelled and no-shows don't count) in the same category. Past the window, or with no visit on file when the studio chose so, booking, rescheduling and the deposit start refuse with 403 and:
{ "error": "Lash Fill can be booked up to 21 days after your last visit. Your last visit was October 1, so it's open until October 22. Pick a day by then or book another service.",
"fillWindow": { "reason": "too_late", "serviceId": "s2", "serviceName": "Lash Fill", "optionName": "3 Week Fill",
"days": 21, "lastVisit": "2026-10-01", "openUntil": "2026-10-22", "closed": false } }
reason is too_late or no_visit (no lastVisit/openUntil then). closed: true means openUntil has passed, so only another service works. Show error as it is, and offer another service (and an earlier day when closed is false).
Checks before booking
await besmi("/discounts/check", { code: "WELCOME10", serviceId: "s1", price: 100 });
// { valid: true, code: "WELCOME10", type: "fixed", value: 10, amountOff: 10 }
await besmi("/gift-cards/check", { code: "ABCD-EFGH-JKMN-PQRS" });
// { card: { last4, balanceCents, currency, usable, canPayDeposit, ... } }
The booking checks both again. Selling gift cards is not part of the API: show a "Give a gift card" section while studio.giftShop.open and link it to studio.giftShop.url, where the purchase, delivery and receipt happen.
Classes
For a studio that teaches (lash education). The same seats, holds, payments, emails and waitlist as the studio's own class pages (/<studio>/classes). Everything is empty (and GET /studio says classes: false) until the studio publishes a class.
GET /classes | published classes that haven't started, soonest first |
GET /classes/{classId} | one class |
POST /classes/{classId}/purchase | hold a seat for 15 minutes and open its payment |
POST /classes/purchases/{purchaseId}/confirm | { clientSecret } after Stripe.js took the payment |
POST /classes/{classId}/waitlist | { name, email, phone? } a spot when it's full |
GET /classes/inquiry | the months a student can pick, plus flexible |
POST /classes/inquiry | a request for a "send a request" class (below) |
A class:
{
id, title, description, coverUrl, location, policy, prerequisites, included: ["Kit"],
tag: "Most requested", // her short card label, "" for none
signup: "pay" | "inquire", // "inquire": no checkout, students send a request
instructor: { name, photoUrl },
sessions: [{ id, startAt, endAt, label: "Sat, Nov 8, 10:00 AM to 4:00 PM" }],
dateLabel, totalHours, seats: 6, seatsLeft: 4,
priceCents: 45000, currency: "USD", payment: "deposit" | "full",
dueNowCents: 15000, dueLaterCents: 30000, // the deposit now, the rest at the class
waitlistEnabled: true, onSale: true, soldOutReason: null | "full" | "started",
url // the studio's own page for it
}
A signup: "inquire" class has no checkout (onSale is always false, purchase answers 409) and may have no dates yet. Students ask instead, and the request lands in the studio's Classes → Requests with a push. Never a phone number:
await besmi("/classes/inquiry", {
classIds: [classId], // one or more of her request classes
month: "2027-02", // from GET /classes/inquiry, or "flexible"
paymentPlan: "yes" | "maybe" | "no",
questions: "", name: "Ana", instagram: "ana.lashes", email: "", // Instagram or email
website: "", // leave empty: a filled one is treated as a bot
});
// 200 { ok: true, id }; 400 INQUIRY_INVALID { field } names what to fix
Buying a seat checks her email like a booking (send the code with POST /verification/email first; a Firebase phone sign-in idToken works too):
const r = await besmi(`/classes/${classId}/purchase`, {
student: { firstName: "Ana", lastName: "Ruiz", email: "ana@example.com", phone: "+13035550123" },
verification: { email: "ana@example.com", code: "123456" },
consent: { transactional: true },
});
// { result: "payment_required", purchaseId, holdExpiresAt,
// payment: { clientSecret, paymentIntentId, connectedAccountId, publishableKey, currency,
// amountCents, feeCents, totalCents, dueLaterCents } }
// a free class: { result: "booked", purchaseId, confirmation }
const stripe = Stripe(r.payment.publishableKey, { stripeAccount: r.payment.connectedAccountId });
// mount a Payment Element with r.payment.clientSecret, then confirmPayment({ elements, redirect: "if_required" })
await besmi(`/classes/purchases/${r.purchaseId}/confirm`, { clientSecret: r.payment.clientSecret });
// 200 { result: "booked", confirmation: { title, studentFirstName, email, paidCents, dueLaterCents } }
// 202 { result: "processing" } try again in a moment; 402 PAYMENT_FAILED
The student gets her confirmation email and the studio a notification, and she is saved as a client tagged "student". Pay within holdExpiresAt or the seat goes back on sale (a late payment still gets the seat if one is free, else a full refund). A full class answers 409 CLASS_FULL with waitlist: true when it has one; her code is still good, so join it:
await besmi(`/classes/${classId}/waitlist`, { name: "Ana Ruiz", email: "ana@example.com" });
// { result: "waitlisted", entryId, already: false }
When a seat opens she gets an email with a link that holds it for her on the studio's class page.
Returning clients
Besmi's own booking page remembers a client on her device and books her as herself ("Booking as Ana"). Your website is on another domain, so it can't use that cookie. Instead, after she proves her email (or phone) once, Besmi gives your site a client token to keep (for example in localStorage) and send back.
POST /clients/session
await besmi("/verification/email", { email: "ana@example.com" }); // she gets the code
const s = await besmi("/clients/session", {
verification: { email: "ana@example.com", code: "123456" }, // or { idToken } (phone sign-in)
});
localStorage.setItem("besmiClient", s.clientToken);
The same proof as POST /bookings. The code is used up here: book with the token, not the code.
200 {
"clientToken": "bct1.eyJ2Ijox...",
"expiresAt": "2027-01-16T01:33:44.924Z",
"returning": true,
"client": { "firstName": "Ana", "lastName": "Diaz", "email": "ana@example.com",
"phone": "+13035550123", "instagram": "anadiaz" },
"answeredQuestionIds": ["q_allergies"],
"upcoming": [
{ "id": "apt_1", "serviceName": "Classic Full Set", "artistId": "uid_sydney", "artistName": "Sydney",
"startAt": "2026-11-17T14:00:00.000Z", "endAt": "2026-11-17T16:00:00.000Z",
"status": "confirmed", "manageUrl": "https://besmi.com/manage/..." }
]
}
| Field | |
|---|---|
client | the studio's record for her, matched on the verified email or phone the way a booking matches it. null when she has none yet (a first-timer). Only her name, email, phone and Instagram: never the studio's notes, tags, spend or anything else |
returning | the deposit rule's own test: true when the studio has a record from a past booking. A record that only came from a waitlist or a class seat is not returning yet. Under "deposits for new clients only", false means a deposit will be asked |
answeredQuestionIds | the studio's "asked once" questions (studio.booking.questions with frequency: "once") she has already answered. Skip them in your form; Besmi applies the same rule on its side anyway. Ids only, never answers |
upcoming | her future appointments at this studio, not cancelled, soonest first, at most 10. manageUrl is the same link her confirmation has (move or cancel under the studio's policy) |
A wrong code is 401 VERIFICATION_FAILED, the same for everyone: nothing says whether an email belongs to a client until she proves it. Session attempts count against the code-check limit (30 per 10 minutes per visitor).
GET /clients/me
const me = await fetch(`${BESMI}/clients/me`, {
headers: { "Besmi-Key": KEY, "Besmi-Client": localStorage.getItem("besmiClient") },
}).then((r) => r.json());
The same payload, read fresh. The token comes back unchanged with its own expiresAt: there is no sliding renewal, so a token always ends 90 days after she last proved her contact. 401 CLIENT_TOKEN_INVALID for a missing, tampered or expired token, or one made for another studio: drop it and ask for a code again.
Booking with the token
Send verification: { clientToken } instead of the code, on POST /bookings (one service or several in items), POST /waitlist, POST /classes/{id}/purchase and POST /classes/{id}/waitlist:
await besmi("/bookings", {
serviceId: "s1", startAt: slot.start,
client: { firstName: "Ana", lastName: "Diaz", email: "ana@example.com", phone: "+13035550123" },
answers: { q_every_time: "No" }, // skip ids in answeredQuestionIds
consent: { transactional: true },
verification: { clientToken: localStorage.getItem("besmiClient") },
});
It counts exactly like a code: the booking is matched to her record on the verified contact, the returning-client deposit rule, members, consent, the blocklist and early access all apply as before. The booking's verificationMethod is client_token.
Token rules
- One studio. A token works only with keys of the studio that issued it.
- One contact. It carries the contact she proved (email, or phone for a phone sign-in). A booking's
client.email(orclient.phonefor a phone token) must be that contact; anything else is400 VERIFICATION_REQUIRED, as if no proof was sent. With an email token you may leaveclient.emailout and the token's email is used. - 90 days, then it stops. No renewal without a new code.
- Stateless and signed.
bct1.<payload>.<signature>. The payload is readable ({ v, typ, businessId, contact, iat, exp }) but any change breaks the signature. It holds no name, customer id or client-list membership. - It is not the booking page's trusted-device cookie and can't stand in for it, nor the other way round (different signing keys).
- Treat it like a session: keep it on her device, send it only to Besmi, and forget it on "Not you?". It never grants anything a code wouldn't.
How the studio sees website bookings
A booking or class seat made through this API is marked with the website it came from: bookedVia: { kind: "website", host: "devoelashes.com" } on the appointment (and on the class seat). The host is the browser's Origin (without www.). A call from your server (no Origin) uses its Referer when that is one of the key's websites, else the key's website when it lists exactly one, else it is unknown and the studio sees "Booked on your website".
- The calendar and the client's visit history say Booked on devoelashes.com where booking-page bookings say "Booked online". A class seat says On devoelashes.com.
sourcestaysbooking_page: website bookings still count as online bookings everywhere Besmi counts them (page stats, the studio's online bookings, reports).bookedViais extra, never a replacement.- A website can't set or fake it: it is never read from the request body.
Static sites and hosting
De Voe Lashes, Revival Beauty and Beauty Aesthetics by Kenia are static Next.js exports (output: "export") on Firebase Hosting, built and deployed by a GitHub Action on every push. What they learned:
- Keep the key in the build, not the repo.
NEXT_PUBLIC_BESMI_KEYfrom a repository secret, the live key in production and a test key locally. With no key, run on a saved sample of her data (a snapshot JSON) so the site still builds and previews, and never pretend to book. - Never fail a build on the network. Fetch at build time for the first paint and SEO, fall back to the snapshot if Besmi can't be reached, then fetch again in the browser.
One page for every post (the
/creativerewrite). A static export can't know tomorrow's post slugs. Build one shell page that reads the slug from the URL and loadsGET /updates/{slug}, and rewrite every post URL to it. Revival Beauty'sfirebase.json:"rewrites": [{ "source": "/creative/**", "destination": "/creative-post/index.html" }]Also prerender the posts that exist at build time (page through
GET /updates?limit=50&offset=...untilnextOffsetisnull), so search engines get real HTML, and use each post'sseofor its<title>and description. A404 NOT_FOUNDfrom/updates/{slug}means the post ended: show your not-found page.- Sitemap and feed. Build them from the same pages: every live post's
slugandupdatedAt(for<lastmod>;publishedAtfor RSSpubDate). Use your own domain in your URLs; each post'surlis its page on her Besmi link if you want it as a fallback. - Images are absolute
https://URLs on Besmi's storage (studio logo and photo, serviceimageUrl, artistphotoUrlandgallery, postphotoUrl, productimages). Use them as they are, or copy them into your build. HonorimageFrame/photoFramewith CSSobject-position. - Structured data. Build your own
LocalBusinessJSON-LD from/studio(name,address,contact.phone,hours) and/reviews(summary). - Analytics. Bookings made on your site are tagged with your domain in Besmi automatically (see "How the studio sees website bookings"). Track your own funnel with your own analytics; never send the client token, a
holdTokenor the email code to analytics.
Go-live checklist
- A live key (
bpk_live_) whose websites list every origin the site is served from:https://studio.comandhttps://www.studio.comare two entries. Redirect one to the other anyway. - The live key is in the deploy secret, not in the repo, and the test key is not in the production build.
- From the live domain, the browser console shows no CORS or
ORIGIN_NOT_ALLOWEDerrors on any page. - The studio is on Icon and her Besmi page is public (
STUDIO_NOT_PUBLICandPLAN_REQUIREDotherwise). - One real booking end to end with the owner watching: the code email arrives, the booking shows in her calendar as Booked on <your domain>, the deposit (if any) lands on her Stripe, the confirmation reaches the client. Then she cancels it from the
manageUrl. - Every section that depends on a switch (waitlist, classes, gift cards, shop, memberships, academy) hides itself when it is off.
- Policies and fees on the site are read from
/studio, not typed in. - Errors show the API's
errorsentence;409sends her back to pick a time;429waitsRetry-After. - A sitemap,
robots.txt, and per-page titles and descriptions fromseo. - A way to rebuild when content changes (browser fetch, or the ETag job).
What it never does
- Show or accept anything private: member contacts, pay, roles, other clients, client records, the studio's Stripe or Besmi account details.
- Book outside the booking page's rules, or set a price, status or payment.
- Charge, refund or cancel a booking. Her
manageUrlhandles moves and cancellations under the studio's own policy. (/bookings/releaseonly lets go of a visit's 15-minute hold before its deposit is paid, with herholdToken.) - Work for another studio, from an unlisted website, with a secret key, or below the Icon plan.