Besmi·Developers

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 pathWhat it doesLimit
GET /studioname, brand, bio, SEO, hours, address, contact, policies, booking rules, gift card shopread
GET /artists, GET /artists/{id or slug}the team a client can book, one artist with her servicesread
GET /servicesthe online menu: categories, services, variants, add-ons, per-artist pricesread
GET /availabilityopen times for one day or up to 31 days, one service or a visit of severalavailability
GET /reviewspublished reviews, paged, with the rating summaryread
GET /updates, GET /updates/{slug}Creative posts (offers, events, news, blog), paged, with tagsread
GET /productsthe shop, to show and link to (buying happens on Besmi)read
GET /membershipsmembership plans on sale, each with its join linkread
GET /classes, GET /classes/{id}in-person classes and seatsread
GET /classes/inquirythe months a "send a request" class offersread
GET /academy, GET /academy/programs/{id or slug}online education: programs, options, prices, rounds, seats, checkout linksread
POST /verification/emailsend her a 6-digit codecheck
POST /discounts/check, POST /gift-cards/checkcheck a code before bookingcheck
POST /clients/session, GET /clients/mea returning client: her token, her details, her upcoming visitscheck / read
POST /bookingsbook one service or a visit of severalbook
POST /bookings/finalizefinish a booking after Stripe.js took the depositfinalize
POST /bookings/releaselet go of an unpaid visit's 15-minute holdbook
POST /bookings/card, POST /bookings/card/confirmsave a card on file after bookingbook
POST /waitlistjoin the appointment waitlistbook
POST /classes/{id}/purchase, POST /classes/purchases/{id}/confirmbuy a class seatbook
POST /classes/{id}/waitlista spot when a class is fullbook
POST /classes/inquiryask about a "send a request" classbook
POST /academy/waitlistjoin an academy program's waitlist while it is closedbook

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 toLink to
move or cancel a visitthe appointment's manageUrl, or studio.booking.appointmentsUrl for all of hers
buy a gift cardstudio.giftShop.url (only while giftShop.open)
buy a producteach product's url, or shopUrl from /products
join a membershipeach plan's joinUrl from /memberships
buy an academy programeach option's checkoutUrl from /academy
fill in an intake formnothing to do: Besmi emails and texts it after she books
contact the studiostudio.contact (email, phone, Instagram). There is no contact form endpoint.

Build a full site, start to finish

  1. 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 has manage_website_keys). Before launch she makes a live key listing her real https:// website(s).
  2. The studio. GET /studio gives the name, logo, colors and fonts (brand), bioText, seo for your <title> and description, hours, address or locations, contact, policies and every booking rule. Show policies.text and policies.fees.lines near the booking button, word for word.
  3. The menu and team. GET /services (group by categories, show imageUrl, variants, addons) and GET /artists (skip the artist step when it answers []: a solo studio).
  4. Reviews and posts. GET /reviews?limit=6 for a proof bar, GET /updates for offers, events and the blog. Give each post its own page from GET /updates/{slug} (blog posts carry bodyHtml).
  5. Open times. GET /availability?serviceId=...&from=...&to=... for a two-week strip, shown in the answer's timezone.
  6. Booking. POST /verification/email, then POST /bookings. Handle booked, deposit_required (Stripe.js, then /bookings/finalize), cardRequired, and status: "pending" (requests she approves). See Book.
  7. Returning clients and My Appointments. POST /clients/session once, keep the token, then GET /clients/me for a "My appointments" page that lists upcoming, each with its manageUrl.
  8. Everything else, only while it is on. The waitlist when booking.waitlist.enabled, classes when studio.classes, gift cards when giftShop.open, a shop when /products says shopEnabled, memberships when /memberships says open, education when /academy answers 200. Hide each section otherwise: the studio switches these herself.
  9. Keep it fresh. See "Knowing when something changed".
  10. 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.

KeyLooks likeWorks from
Livebpk_live_ + 32 charactersthe https:// websites listed under it
Testbpk_dev_ + 32 charactersthose, 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.com and https://www.devoelashes.com are two entries. No paths, no wildcards, no plain http (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_ and rk_ keys and any Authorization header 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.

StatusCodeMeaning
400SECRET_KEY_REFUSED, AUTHORIZATION_NOT_ACCEPTEDwrong kind of credential
400VERIFICATION_REQUIRED, CONSENT_REQUIRED, MISSING_*, INVALID_PHONE, BAD_*, EMAIL_MISMATCHfix the request; field names the input (client.phone, client.firstName, serviceId, ...)
400(from the booking page) with field / questionIda required field or question
401MISSING_KEY, INVALID_KEYno key, or it's off or rotated out
401CLIENT_TOKEN_INVALIDGET /clients/me: the client token is missing, tampered, expired or another studio's
402PLAN_REQUIREDthe studio isn't on Icon
403STUDIO_NOT_PUBLICthe studio isn't taking bookings yet (its page isn't public)
403ORIGIN_NOT_ALLOWEDthis website isn't on the key's list (no CORS headers: the browser shows a CORS error)
403blocked: truethis client can't book online
403fillWindow: {...}the service (or option) is booked past her fill window, or she has no visit on file; see Fill windows
404STUDIO_UNAVAILABLE, ARTIST_NOT_FOUND, SERVICE_NOT_FOUND, ...
401VERIFICATION_FAILEDthe email code (or phone sign-in) didn't check out
402PAYMENT_FAILEDa class seat's payment didn't go through
400BAD_ITEMS, BAD_ITEM_COUNT, DUPLICATE_SERVICEa visit's items (see Several services); field names the item, e.g. items[1].variant
400DISCOUNT_SINGLE_SERVICE_ONLYa discount code sent with items (the booking page has none for a visit either)
400PAST, LEAD_TIME, TOO_FAR, NOT_RELEASEDthe time is outside the studio's notice / booking window / book release
400BAD_PREFS, BAD_DATES, NOTE_TOO_LONG, WAITLIST_SINGLE_SERVICE_ONLYa waitlist request (see Waitlist)
401INVALID_HOLD_TOKENthe holdToken isn't this visit's (or this studio's)
402DEPOSIT_NOT_PAIDfinalize for a visit whose deposit wasn't paid
403WAITLIST_OFFthe studio's waitlist is off (or not on its plan)
409SLOT_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.
409EXPIREDa visit's 15-minute hold ran out before the deposit landed
502DEPOSIT_SETUP_FAILEDa visit owed a deposit and the payment couldn't be set up; nothing is booked (the hold is let go), try again
409CLASS_FULL (with waitlist), CLASS_STARTED, CLASS_CLOSED, SEATS_OPENclasses: full, begun, not selling, or a seat is open (buy it)
404ACADEMY_NOT_FOUND, PROGRAM_NOT_FOUND, NO_WAITLIST; 409 PROGRAM_OPEN; 400 BAD_WAITLISTthe Academy (see Academy)
304(no body)a GET sent with If-None-Match and nothing changed (see Knowing when something changed)
429RATE_LIMITEDslow down; see Retry-After
500SERVER_ERRORsomething 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, /services and the rest on each page view (answers are no-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 GET carries an ETag, a fingerprint of exactly what it answered. Keep it and send it back as If-None-Match: the answer is 304 Not Modified with no body when nothing changed, or 200 with the new content (and a new ETag) 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, bioTextbio 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.
seowhat 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, photoUrltop level; null when not set. photoUrl is the studio's profile picture.
citynull when the studio hides its location
addressan 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
hourskeyed 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.requiresApprovaltrue: new bookings come back status: "pending" until the studio confirms
booking.cardOnFiletrue: a booking may answer cardRequired: true (see Card on file)
booking.depositnull 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.giftCardsgift cards can be used when booking
booking.verification["email"]: how a website proves the client (the email code)
booking.clientFieldseach 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.referralOptionsthe choices for referral ("How did you hear about us?")
booking.questionsthe 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.processingFeenull, 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.appointmentsUrlher own page on Besmi to see, move or cancel what she booked ("Already booked? Manage or reschedule" on the booking page)
booking.datePickerhow the studio set its booking page to show days: "list" (a row of days) or "calendar" (a month)
policies.textthe 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.showOnPagethe 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.cancellationWindowHourshours before a visit she can still cancel, or null
policies.feesthe 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
classestrue when it has published classes
giftShopher 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? }:

typeyes_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)
serviceIdsask 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
categoryIdthe same id as category.id (or null), for filtering
priceFromtrue 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
imageUrlnull when the service has no photo
variantsoptions she picks one of; send the name as variant when booking
addonswhat can be added to this service; send ids as addonIds
offeredDaysweekdays it can be booked, 0 = Sunday; null = every open day
fillWindowDaysbookable 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"
standalonean add-on the studio lets clients book on its own (no category, no add-ons)
artistsper 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
serviceIdrequired
date=YYYY-MM-DDone day: { slots: [{ start, end }] }
from, toup to 31 days: { days: { "YYYY-MM-DD": [{ start, end }] } }
artistIdher id or slug; omit or any for anyone who does the service
varianta variant's name
addonIdscomma separated
withArtists=1one day, any artist: each slot gets artistIds, who is free then
locationIdany artist at this place: the artists who work there, plus those with no place of their own
itemsseveral 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
authorfirst name and last initial ("Ana D."), or just the first name
title, reply, artistIdnull when there is none
photosURLs of approved photos only
serviceNamethe service as it was named when reviewed (may be "")
nextCursorpass as cursor for the next page; null at the end
summarythe 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
kindupdate, tour, class, promo, news, blog
limit, offset, nextOffset, totallimit 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
tagseach 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=
updatedAtwhen the post last changed (never before publishedAt): <lastmod> in a sitemap
urlits page on her Besmi link (besmi.com/<slug>/creative/<slug>)
feed.titlewhat the studio calls the section; custom: false means Besmi's default, which a site may replace with its own
feed.journalTitlewhat the studio calls its blog (journalCustom: false = the default, "Journal")
feed.cardsher 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)
tagfilter: posts carrying this tag (any case)
excerpt, readMinutes, tags, author, seoblog 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
eventdates (YYYY-MM-DD, the studio's calendar), city, venue; null when not set
photoFramehow 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
codean offer's discount code, when she added one
cta.typebook (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
moneycents, in currency (the shop's unit; /services is in dollars)
productsher 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
priceCentsthe lowest price (the cheapest option's, with variants); priceRange [low, high] when options differ, else null
compareAtCentsthe "was" price when on sale, else null
variantsoptions 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, leftleft only when 3 or fewer remain (as the shop shows "2 left"); never the stock count
badge"Best seller", "24 sold", or null
collectionsher 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: falsethe 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
openfalse (with url: null, plans: []) when nothing is on sale: memberships off, not on her plan, or payments not set up
priceCents, valueCentscents. valueCents is what the included services cost each cycle without the plan (her menu prices)
benefits, disclosureready-made lines. Show disclosure near the join button, as her page does
minimumCyclespayments 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 /academyher 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 bookedpaid (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_PAIDno payment went through yet
409 EXPIREDthe 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_FOUNDnot 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_OFFthe studio's waitlist is off
400 WAITLIST_SINGLE_SERVICE_ONLYitems was sent
400 BAD_PREFS (field: prefs.mode, prefs.withinDays, prefs.timeOfDay) / BAD_DATES / NOTE_TOO_LONGher choices
400 MISSING_SERVICE, SERVICE_NOT_FOUND, BAD_VARIANT, BAD_ADDON, ARTIST_NOT_FOUND, ARTIST_DOESNT_OFFER, LOCATION_NOT_FOUNDwhat she asked for
400 VERIFICATION_REQUIRED, CONSENT_REQUIRED, MISSING_FIRST_NAME, MISSING_PHONE, INVALID_PHONE, EMAIL_MISMATCH; 401 VERIFICATION_FAILEDas for a booking
403 with blocked / earlyAccess / fillWindowas 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 /classespublished classes that haven't started, soonest first
GET /classes/{classId}one class
POST /classes/{classId}/purchasehold 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/inquirythe months a student can pick, plus flexible
POST /classes/inquirya 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
clientthe 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
returningthe 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
answeredQuestionIdsthe 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
upcomingher 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 (or client.phone for a phone token) must be that contact; anything else is 400 VERIFICATION_REQUIRED, as if no proof was sent. With an email token you may leave client.email out 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.
  • source stays booking_page: website bookings still count as online bookings everywhere Besmi counts them (page stats, the studio's online bookings, reports). bookedVia is 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_KEY from 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 /creative rewrite). A static export can't know tomorrow's post slugs. Build one shell page that reads the slug from the URL and loads GET /updates/{slug}, and rewrite every post URL to it. Revival Beauty's firebase.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=... until nextOffset is null), so search engines get real HTML, and use each post's seo for its <title> and description. A 404 NOT_FOUND from /updates/{slug} means the post ended: show your not-found page.

  • Sitemap and feed. Build them from the same pages: every live post's slug and updatedAt (for <lastmod>; publishedAt for RSS pubDate). Use your own domain in your URLs; each post's url is 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, service imageUrl, artist photoUrl and gallery, post photoUrl, product images). Use them as they are, or copy them into your build. Honor imageFrame / photoFrame with CSS object-position.
  • Structured data. Build your own LocalBusiness JSON-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 holdToken or the email code to analytics.

Go-live checklist

  1. A live key (bpk_live_) whose websites list every origin the site is served from: https://studio.com and https://www.studio.com are two entries. Redirect one to the other anyway.
  2. The live key is in the deploy secret, not in the repo, and the test key is not in the production build.
  3. From the live domain, the browser console shows no CORS or ORIGIN_NOT_ALLOWED errors on any page.
  4. The studio is on Icon and her Besmi page is public (STUDIO_NOT_PUBLIC and PLAN_REQUIRED otherwise).
  5. 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.
  6. Every section that depends on a switch (waitlist, classes, gift cards, shop, memberships, academy) hides itself when it is off.
  7. Policies and fees on the site are read from /studio, not typed in.
  8. Errors show the API's error sentence; 409 sends her back to pick a time; 429 waits Retry-After.
  9. A sitemap, robots.txt, and per-page titles and descriptions from seo.
  10. 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 manageUrl handles moves and cancellations under the studio's own policy. (/bookings/release only lets go of a visit's 15-minute hold before its deposit is paid, with her holdToken.)
  • Work for another studio, from an unlisted website, with a secret key, or below the Icon plan.