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();

See Get the studio settings.

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.packages

This 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.

On this page