Booking on your site
Services, free start times and an order, straight from the browser with a publishable key.
Build it along
The interactive version is at /guide: you run each call against a demo studio, or against your sandbox with your own test key, and see the form fill. This page is the short reference version.
A booking form on a studio's own site needs four calls (plus an optional read of
the studio itself, below). Production is https://api.shyni.io; staging is
https://api.shyni.dev. Use a publishable key
(shyni_pk_...). It may sit in the page, and it can do exactly these calls.
Before you start, the owner of the key must list your site's hostname: in the
portal under Settings → Allowed websites (organization), or with
allowedDomains on PATCH /businesses/{id} (one business). Otherwise every call answers 403 ORIGIN_NOT_ALLOWED. See
Keys and auth.
const API = "https://api.shyni.io/api/v1";
const headers = { Authorization: "Bearer shyni_pk_live_..." };Read the studio
GET /studio works with a publishable key and returns the public studio view:
only what the published storefront shows (name, address, phone, public
e-mail, timezone, currency, deposit and cancellation settings, bookingPaused), read
from the published definition. It carries no contact e-mail, bank account,
allowed domains, plan or operational switches; a secret key gets the full
settings view instead.
const { data: studio } = await (await fetch(`${API}/studio`, { headers })).json();Studio terms
When the studio has its own terms and conditions, GET /studio returns
terms with url, version and publishedAt. The object is absent when the
studio has none; then there is nothing to show or send.
Show a sentence with a link to terms.url above the booking button, for example
"By booking you agree to the studio's terms and conditions". They are the
studio's own terms; Shyni is the platform. Keep terms.version and send it as
acceptedTermsVersion when you create the order (step 3). Omit the field when
your site does not show the terms; the order then records no accepted version.
1. Services
const { data } = await (await fetch(`${API}/services`, { headers })).json();
// data.categories, data.services, data.packagesThis reads the published catalog. packages is empty on plans without
packages. Keep the id of what the customer picks. Pass service and package
ids as serviceIds below. See List services.
Each service has variants: a code, a priceMultiplier and extraMinutes
per variant (what a variant means depends on the business: the size of a dog for a
grooming salon, the vehicle size for an auto detailing). See
Subject types and variants. Let the customer pick one and use its code as variant
below. A service may also carry questions, each with options. Their ids go
into optionAnswers of the order, and a question with required: true must be
answered.
A service also carries pricingMode (EXACT, FROM or ON_REQUEST), minAdvanceDays,
multiDay, includes (what the price includes), image (or null) and
storefrontVisible. Only EXACT services can be booked through POST /orders;
FROM and ON_REQUEST are inquiries, and the orders endpoint refuses them. A
publishable key never receives a service with storefrontVisible: false.
2. Availability
Which days can be booked in a range:
const cal = await (
await fetch(`${API}/availability/calendar?fromDate=2026-11-01&daysCount=30&serviceIds=${ids}`, { headers })
).json();
// { unavailableDays: [...], earliestDate, latestDate, workDays, closedDays }Which start times exist on the chosen day:
const starts = await (
await fetch(`${API}/availability/starts?date=2026-11-03&serviceIds=${ids}&variant=MEDIUM`, { headers })
).json();
// { data: [{ start: "2026-11-03T08:00:00Z", end: "2026-11-03T09:30:00Z" }] }serviceIds is comma-separated. The optional variant adds that variant's
extraMinutes to the duration, so pass the same value you send when creating
the order. The calendar takes it too. Starts beyond the one-year horizon are not
offered. See
Get the availability calendar and
List start times.
Configurator services
For a service with questions, the picked options lengthen the work. Send the
answers as optionAnswers=<questionId>:<optionId>[,<questionId>:<optionId>] to both
availability calls, and send the same answers in the order, otherwise the window
is too short and the order is refused.
const answers = `${questionId}:${optionId}`;
const starts = await (
await fetch(`${API}/availability/starts?date=2026-11-03&serviceIds=${ids}&variant=MEDIUM&optionAnswers=${answers}`, { headers })
).json();An unknown question or option, a question answered twice, a required question left
unanswered, a malformed pair, or answers on a selection with no configurator
service (add-ons alone) is 400 INVALID_INPUT with field optionAnswers, the
same as POST /orders.
3. Create the order
Send the start and end of one entry together. Generate one
Idempotency-Key per submit attempt, and reuse it when the customer retries the
same form, so a double click books once.
const res = await fetch(`${API}/orders`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
serviceIds: ids,
scheduledStart: slot.start,
scheduledEnd: slot.end,
customer: { name, email, phone },
variant: "MEDIUM",
// optionAnswers: [{ questionId, optionId }] for the service questions
// acceptedTermsVersion: studio.terms.version, if you show the studio's terms
// attribution: { utmSource, ... }, metadata: { ... } -- see "Conversion tracking"
}),
});Required: serviceIds, scheduledStart, scheduledEnd, customer (name,
email, phone) and variant. Every body is strict: a field the endpoint does
not define is 400 UNKNOWN_FIELD with field naming it (customer.foo), and
nothing is stored.
variant is required and must be a code from the service's variants; an
unknown code is 400 INVALID_INPUT with field variant. subject is optional unless the business's subject type has a required attribute; then 400 INVALID_SUBJECT names it in field. A publishable key cannot read GET /subject-types, so the form's fields come from your server (secret key with catalog:read) or from your own configuration. A subject looks like {"type": "DOG", "attributes": {"name": "Biscuit", "breed": "Beagle"}}
(for auto detailing, {"type": "CAR", "attributes": {"plate": "1AB 2345", "make": "Skoda", "model": "Octavia"}}).
attributes is a map validated against the studio's subject type, which
GET /subject-types lists (a studio of industry GENERIC defines its own
with typed attributes: text, number, choice, bool; a detailing studio has the
preset CAR type). A subject that does not fit
the type is 400 INVALID_SUBJECT with field naming the attribute key (or
subject.type), and nothing is stored. The order returns it as
subject {id, type, attributes, summary}.
201 returns the order with orderUrl, the customer's page for it (cancel,
reschedule). When the services need a deposit the response also has
depositRequired: true and, if the studio takes online payments, a
paymentUrl: redirect the customer there. See
Create an order.
The deposit hold
An unpaid order with a deposit holds the slot, and holdUntil (an ISO-8601
instant) says until when: 30 minutes after creation when the customer pays by
card through paymentUrl, 3 hours for bank transfer. It is absent when the order
holds no slot for a deposit. Show the customer a countdown to holdUntil next to
the payment button:
const order = await res.json();
if (order.holdUntil) {
const msLeft = new Date(order.holdUntil).getTime() - Date.now();
startCountdown(msLeft); // and send the customer to order.paymentUrl ?? order.orderUrl
}When the countdown reaches zero without payment, the slot is released, the order
is cancelled (the order.cancelled webhook carries cancelledBy: HOLD_EXPIRY) and
the window can be booked by someone else. Offer to start again from the starts.
Conversion tracking
attribution and metadata are optional. attribution records where the customer
came from; every field is an optional string: utmSource, utmMedium,
utmCampaign, utmTerm, utmContent, referrer, gclid, gbraid, wbraid and
fbclid. A value over its cap is 400 INVALID_INPUT with field
attribution.<name>, and an unknown key is 400 UNKNOWN_FIELD. metadata is your
own flat string-to-string data (at most 20 keys; a key is 1-40 characters of
A-Z a-z 0-9 _ . -; a value is a string of at most 500 characters). The studio may
see it, so keep secrets out.
{
"attribution": { "utmSource": "newsletter", "utmCampaign": "autumn", "gclid": "Cj0KCQ..." },
"metadata": { "cartId": "c-7731" }
}Both come back unchanged in the create response, in GET /orders/{id} and the order
list, and in the order.* webhooks, so you can match a
paid booking to the campaign on your side.
The customer gets the same e-mails as after a booking on the studio's public
page. A publishable key is limited to 10 bookings per hour per visitor IP
(shared with that page) and 60 per hour per studio (counted separately from the
page). At a studio that confirms orders manually it can also have at most 10
unconfirmed orders at once; the 11th is 429 PENDING_ORDERS_LIMIT. See
Rate limits.
4. Handle the taken slot
Someone else may book the window between steps 2 and 3. The answer is
409 SLOT_UNAVAILABLE and nothing was created:
{
"code": "SLOT_UNAVAILABLE",
"message": "The requested time slot is no longer available. Pick another start from GET /api/v1/availability/starts.",
"field": "scheduledStart"
}Ask for the starts again and let the customer pick another. Branch on code,
never on message.
if (res.status === 409) {
const err = await res.json();
if (err.code === "SLOT_UNAVAILABLE") return showFreshStarts();
}409 TERMS_VERSION_STALE (field acceptedTermsVersion) means the studio
published new terms after you read GET /studio. Nothing was created and the
refusal is not stored under the Idempotency-Key. Read the studio again, show
the new terms and resend with the new terms.version. The order views return
acceptedTermsVersion when one was sent.
409 STUDIO_NOT_PUBLISHED means the studio is not published and takes no
bookings (a test studio is always bookable); show a "booking unavailable"
message instead of retrying.
409 IDEMPOTENCY_IN_PROGRESS also exists: a request with the same key is still
running, so retry shortly. Other refusals are in Errors.