Keys
A key belongs to one business and is created in your own settings. It is shown once, at creation, and stored hashed — we cannot show it to you again, and neither can anybody who reaches the database.
Every request carries it as a bearer token. The tenant is resolved from the key and never from the body, so a key cannot be pointed at somebody else's business by changing a field.
curl https://app.voltsbook.com/v1/ping \
-H "Authorization: Bearer vb_live_…" /v1/ping echoes the business the key
resolved to and the webhook topics this deployment can emit. Use it as a credential
check before you build anything else.
Idempotency
Send Idempotency-Key on any write marked
Idempotency-Key in the table below. Repeat
the request with the same key and you get the original response back rather than a
second appointment; repeat it with a different body and you get
409 idempotency_key_reused rather than a
silent second action. Writes without the badge accept the header and ignore it, so
retrying one of those is a fresh action — read before retrying.
This matters most on the request you are least sure landed — a timeout where the booking may or may not exist. Without a key, the safe thing to do is read before retrying; with one, retrying is the safe thing to do.
curl -X POST https://app.voltsbook.com/v1/bookings \
-H "Authorization: Bearer vb_live_…" \
-H "Idempotency-Key: 8f2c1e40-…" \
-H "Content-Type: application/json" \
-d '{"serviceId":"…","staffId":"…","startAt":"2026-08-11T09:30:00Z",
"customer":{"firstName":"Ada","email":"ada@example.com"}}' A field we don’t know is a 400
Every write on this API refuses a field it does not recognise, naming it. It does not
quietly drop it and answer 200. That
matters because the field you are most likely to get wrong is one whose real name is
close to the one you typed — a service’s duration is
durationMinutes and an extra’s is
extraDurationMinutes — and a write that
succeeded while changing nothing is indistinguishable from one that worked.
Only the top level of the body is checked, so a misspelling nested inside a form
field or a flow card is still ignored. Leaving a field out is never an error: every
PATCH is a genuine partial update, and
an empty body is a legal no-op.
curl -X PATCH https://app.voltsbook.com/v1/services/$SERVICE_ID \
-H "Authorization: Bearer vb_live_…" \
-H "Content-Type: application/json" \
-d '{"price": 12000}'
{"error":{"code":"validation_failed","message":"the request failed validation",
"details":{"formErrors":["Unrecognized key(s) in object: 'price'"],"fieldErrors":{}}}} The list price is not the price
priceMinor on
GET /v1/services is the catalog figure.
It is what the service is listed at — before tax, before a chosen staff member’s own
price, before a custom duration, before add-ons and before any coupon. On a business
with one 20% tax, a 3500 list price is a
4200 bill. Never read it to a customer.
POST /v1/price-preview is the quote. Send
it the selections you are about to book — or just a
reservationId for a slot you are already
holding — and it answers with the figure that booking will produce, computed by the
same pricing code the booking transaction runs. Quote and charge cannot diverge,
because there is only one of them.
totalMinor is the bill;
payableTodayMinor is what the business
takes now, and the two differ when the service asks for a deposit. Both appear on the
booking object too. unknowns[] names
anything the quote could not settle — live offers, a gift card, a refused coupon —
so a total is never quietly missing something. Nothing is written: no slot is held,
no coupon use is spent, no stock is claimed.
couponCode is accepted here and on
POST /v1/bookings, validated against the
business’s own coupons — an unknown or expired code is refused rather than ignored,
so preview it before you promise a discount. Gift cards are not accepted on this API:
a gift card is a payment rather than a price, and redeeming one needs the cardholder.
curl -X POST https://app.voltsbook.com/v1/price-preview \
-H "Authorization: Bearer vb_live_…" \
-H "Content-Type: application/json" \
-d '{"serviceId":"…","staffId":"…","startAt":"2026-08-11T09:30:00Z"}'
{"currencyCode":"USD","totalMinor":4200,"payableTodayMinor":4200,"taxTotalMinor":700,
"lines":[{"key":"service","label":"Haircut","amountMinor":3500},
{"key":"tax:…","label":"Sales tax","amountMinor":700}],
"unknowns":[],"couponMessage":null} Meta
Check a key works and find out what this deployment can do before you build against it.
/v1/ping Health and capability probe
Echoes the tenant resolved from the API key, the API version, the key’s effective scopes (what `[]`-scoped legacy keys mean is already resolved — a key that would get 403 `insufficient_scope` on an admin route can see why here), and the webhook topics this deployment can emit. Integrators use it as a credential check.
/v1/openapi.json No key needed This document
Served without authentication so a client can be generated before a key is issued.
Catalog
What is bookable: services with their durations and LIST prices, the people who perform them, and where. A list price is not a quote — price-preview under Bookings gives the figure a customer will actually be charged.
/v1/services Bookable services
Everything needed to BOOK, in one call: each service’s id, name, category, duration, list price (minor units), capacity, its `extras` (each with its own id, price and added duration), any custom `durations`, and a ready `bookingUrl` deep-linked to the booking page preselected on that service. `priceMinor` IS A LIST PRICE, NOT A QUOTE. It is what the service is listed at — before tax, before a chosen staff member’s own price, before a custom duration, before add-ons and before any coupon — so on a business with one 20% tax a `3500` list price is a `4200` bill. Do not read it to a customer: call `POST /v1/price-preview` with the selections you are about to book and quote its `totalMinor`. A service with `unlisted: true` does not appear on the booking page’s own list but books normally through its `bookingUrl` — hand the customer the link rather than telling them to find it on the page. A service with `atCustomerAddress: true` is a MOBILE service performed at the customer’s own address: ask for the address and send it as `address` on `POST /v1/bookings`, which refuses such a booking without one (`CUSTOMER_ADDRESS_REQUIRED`). A service with `hideTimeOnBooking: true` is booked DAY-ONLY on the booking page: the customer picks a date and the first available start is taken quietly. Mirror that manner — ask which day, call `GET /v1/availability` for it, and book its first slot; the API itself always takes and returns real instants. Each service also lists its booking-form questions under `forms` — `fields[]` of `{ key, label, type, required, choices, helpText }`, public questions only. ASK THEM BEFORE SUBMITTING and send the answers as `formAnswers` on `POST /v1/bookings`, keyed by `key`; a missing required answer refuses the booking naming each fieldKey in `details.issues[]`. `required` is the field’s own flag — conditional rules evaluated at submit may hide a field or require one this flag does not, and the refusal names exactly what is missing. A `file`-type field cannot be answered through this API today; if one is required, book through the booking page instead.
/v1/staff Bookable staff
Who can perform a service: each staff member’s id, name and the services they are assigned to. Pass a `staffId` to `getAvailability` and `createBooking` to book a named person, or omit it to let the business pick. NO PRICES ARE LISTED HERE ON PURPOSE — a business may charge differently for the same service depending on who performs it, so the person you choose can change the bill. Send the `staffId` to `POST /v1/price-preview` and it answers with that person’s price.
/v1/locations Bookable locations
/v1/products Products sellable alongside a booking
What `POST /v1/bookings` will accept in `products`. Pass `serviceId` to narrow the list to what that service is offered with; an empty `serviceIds` on a product means every service. `stockQuantity` is ADVISORY — it is a display figure, never a reservation, and what can actually be sold is settled inside the booking transaction. The business’s own purchase cost is not published here. Products the business marked counter-only (`offerDuringBooking: false`) are not listed, and a booking naming one is refused with `PRODUCT_NOT_OFFERED_ONLINE` — they are sold from the panel only.
/v1/services Idempotency-Key Create a service
The same schema the panel’s service editor saves — duration, price in minor units, capacity, buffers, deposit and recurrence policy. It is bookable immediately once staff are assigned. Supply an `Idempotency-Key` so a retry replays the original response instead of creating a duplicate service.
/v1/services/{id} Change a service
Partial update — send only the fields to change.
/v1/services/{id} Delete a service
/v1/services/{id}/extras Add an extra to a service
/v1/services/{id}/extras/{extraId} Change an extra
/v1/services/{id}/extras/{extraId} Remove an extra
/v1/products Idempotency-Key Create a product
Priced in the TENANT’s currency, which comes from its settings and is never accepted from the request. Requires the plan to include products. Supply an `Idempotency-Key` so a retry replays the original response instead of creating a duplicate product.
/v1/products/{id} Replace a product
PUT, not PATCH: the payload is the WHOLE product, exactly as the panel saves it. Stock is editable here (receiving a delivery is an admin setting stock), but every SALE moves it through the booking transaction’s atomic claim, never through this endpoint.
/v1/products/{id} Delete a product
Availability
The times a booking would actually be accepted. Computed from opening hours, the person, the service, holidays, blocked time and what is already booked — so a slot you are offered is one you can take.
/v1/availability Bookable slots over a date range
The same availability engine, with the same rules, that a human booking through the widget gets. Slots held by a live reservation are already excluded. Omit `staffId`, or send `any`, to search across every qualified staff member. SAYING A TIME OUT LOUD: every slot carries `say` — "Saturday 22 August at 9:00 AM", already in the business’s timezone — read it VERBATIM. `localTime` and `startLocal` are the same wall clock in machine shapes. NEVER convert `startIso` yourself: it is UTC, and reading it as local time is wrong by the whole UTC offset. `startIso` exists to be SENT BACK as `startAt` on a reservation or booking, not to be spoken. `perDay`, `spread` and `limit` shape the ANSWER, never availability itself — `perDay=4` keeps the first four starts of each day; add `spread=true` to get the four SPACED across the day instead (first and last bookable slot always included — right for offering a caller times across the whole day rather than four openings in the first hour); `limit` caps the whole list, and `totalSlots` always carries the pre-shaping count. When the requested window has NO slots the response explains itself: `emptyReason` is one of `no_qualified_staff` (nobody is assigned to the service), `outside_booking_horizon` (past how far ahead the business takes bookings), `closed` (no schedule is open on those days), `duration_exceeds_opening_hours` (the booking is longer than any open day — no start time can EVER fit; do not retry other dates), `too_soon` (inside the minimum-notice window), `all_booked`, or `limit_reached` (a business-wide or service/staff-scoped booking limit — not the availability itself — has used up what this business allows for the period; a PER-CUSTOMER limit cannot be seen here, since nothing before a reservation or booking proves who is booking, and is instead a `409 LIMIT_REACHED` from those endpoints), with `emptyDetail` carrying one human sentence to relay. The empty response also carries `nextAvailable` — the earliest bookable slot after the window (with the same `say` field to read aloud, found under the same service/staff/location inputs, scanning at most 60 days past your window and stopping earlier if the business’s booking horizon does; `null` means nothing is open in THAT span, which is not the same as “this business has nothing” — say so). Offer it to the caller instead of a bare no: “we’re full that day — the next opening is…”. A response that has slots carries `emptyReason: null` and `nextAvailable: null`.
Reservations
Hold a slot briefly while a customer finishes deciding. A reservation is not a booking and expires on its own.
/v1/reservations Hold a slot briefly
Optional first step of the 3-step flow. Returns a reservation id and its expiry; the hold is counted as occupancy so no one else is offered the slot. `ttlSeconds` is CLAMPED to 60–3600 rather than rejected outside it. Send either `startAt`, or both `date` and `time`.
/v1/reservations/{id} Inspect a hold
/v1/reservations/{id} Release a hold early
Puts the slot back on the market immediately instead of waiting for the TTL.
Bookings
Quote a price, then create, read, reschedule and cancel appointments. Every write goes through the same pipeline the booking page uses, so nothing reached this way behaves differently — and price-preview runs the same pricing code the booking itself runs, so a quote and a charge cannot disagree.
/v1/bookings Search bookings
Paginated. `phone` is digit-normalised before matching, so a national and an international rendering of the same number both find the booking. The shape is identical to the webhook payload.
/v1/bookings/{id} One booking
/v1/price-preview What this booking will cost — quote from HERE, never from the catalog price
Call this before you say a price out loud, and read the caller `totalMinor` from the answer. `priceMinor` on GET /v1/services is the CATALOG price and is usually NOT what the business charges: it is before tax, before a chosen staff member’s own price, before a custom duration, before add-ons and before any coupon. On a shop with one 20% tax a £35.00 catalog price is a £42.00 bill. Send the same selections you will send to POST /v1/bookings (or just a `reservationId` to price a slot you are already holding) and you get the figure that booking will produce, computed by the same pricing code the booking itself runs. READ BACK: `totalMinor` is the whole bill and `payableTodayMinor` is what the business takes now — they differ when the service asks for a deposit, and the caller needs to hear both. `lines[]` is the itemised breakdown (`label` is the business’s own wording, safe to read aloud) and `taxTotalMinor` is how much of the total is tax. On a shop whose tax is INCLUDED in the price, the tax line reads `amountMinor: 0` with the figure in `informationalMinor` — say that number, not the zero. HONESTY: `unknowns[]` names anything this quote could NOT settle — `offer` (the shop has live offers that may reduce the price at booking), `coupon` (the code was refused or is conditional, and `couponMessage` says why in words you can read to the caller), `history` (a live rule prices on the customer’s own booking history, which this door cannot read), `stored_value` (this shop sells pre-paid passes for this service; if the caller holds one it is drawn automatically at booking and the bill may come to nothing, so quote the figure as the price if they have no pass and say the pass will be used if they do). Say the total as the price; mention an unknown rather than promising a figure it might change. This creates NOTHING: no slot is held, no coupon use is spent and no stock is claimed, so quoting costs the caller nothing and you may quote as often as the conversation needs.
/v1/bookings Idempotency-Key Create a booking
Send either a `reservationId` (3-step flow) or a `serviceId` with `startAt` (or `date` and `time`) for the 2-step flow. The full booking pipeline runs — validation, server-side pricing, workflows and webhooks — under CUSTOMER-origin rules: the API never receives the admin overlap bypass. Prices and totals are recomputed server-side and are never accepted from the caller. If the service asks booking-form questions (`forms` on `GET /v1/services`), send the answers as `formAnswers`; a missing required answer refuses with `validation_failed` and `details.issues[]` of `{ fieldKey, message }`. QUOTE FIRST: call `previewPrice` with the same selections and tell the caller `totalMinor` before you book — the catalog `priceMinor` is not the price. `couponCode` applies a discount code the caller gave you; it is validated against the business’s own coupons, so an unknown or expired code refuses the booking rather than discounting it — preview it first and read `couponMessage` if it is refused. A gift card cannot be used on this API. THE RESPONSE SHAPE: `bookings[]` is the complete answer — an ARRAY of booking objects (each the same shape as `GET /v1/bookings/{id}`) even when one appointment was created, plus `seriesId` (null outside a recurring series). `booking` is a convenience alias: the single created booking when exactly one was created, null otherwise — read it for the common case, iterate `bookings[]` to be exhaustive. READING THE CONFIRMATION BACK: each booking carries `say` ("Saturday 22 August at 9:00 AM", already in the business’s timezone) plus `localDate`/`localTime`/`startLocal`/`endLocal` — confirm the time with the caller by reading `say` verbatim. NEVER convert `startAt` yourself; it is UTC and reading it as local time is wrong by the whole UTC offset. Supply an `Idempotency-Key` to make a retry replay the original response instead of double-booking. THE CUSTOMER’S CONTACT DETAILS: a business matches repeat customers on ONE of email or phone, its own choice. If the email you send is already on a DIFFERENT customer’s record the booking is refused with `conflict` and `details.reason` of `CUSTOMER_EMAIL_TAKEN` — the address belongs to someone else here, so retrying the same body will never succeed. Confirm the address with the caller and send the corrected one, or book against the existing customer. A phone number that another customer also uses is fine and is never refused: households share a line.
/v1/bookings/{id}/cancel Idempotency-Key Cancel a booking
Takes the same status-change path as an admin cancellation, so workflows and webhooks fire identically. Answers 200 (since 2026-08-15; it briefly answered 201, which claimed a cancellation created something). `phone` IS REQUIRED: it must be the booking customer’s number, compared on trailing digits so a national and an international form of the same number match. An absent phone is refused with `caller_verification_required` and a wrong one with `forbidden` — an appointment id is not a secret, so the number is what authorizes acting on someone else’s booking. A booking whose customer has no phone on file cannot be cancelled through this API.
/v1/bookings/{id}/reschedule Idempotency-Key Move a booking
Validates the new slot with the same engine a fresh booking uses. Send either `startAt`, or both `date` and `time`. `phone` IS REQUIRED, on the same terms as cancel: moving a stranger’s appointment is the same authority as cancelling it.
Settings
Booking rules, custom forms and the status vocabulary — full admin control for an agent whose key carries the settings.manage scope. Keys minted without it, including every key minted before scopes existed, are refused here and keep taking bookings exactly as before.
/v1/settings/booking The booking rules and settings
Everything the panel’s booking-settings screen edits: slot step, notice, horizon, overlap policy, statuses mapping, customer-panel policy and more. The stored captcha secret is write-only and never present.
/v1/settings/booking Change booking rules and settings
Partial update — send only the fields to change. The same validation the panel’s save runs: status fields must name a status the business actually has, and nothing priced or computed is accepted. STRICT (2026-08-15): an unknown or unwritable field name is refused with `validation_failed` naming it, never silently ignored — a misspelt field that answered 200 and changed nothing was the worst possible outcome for a machine caller.
/v1/appointment-statuses The appointment status vocabulary
What the `status` on a booking can be, with each status’s label and whether it keeps the time slot busy. Readable with the ordinary booking scope, because a bookings list cannot be interpreted without it; CHANGING the vocabulary needs `settings.manage`.
/v1/appointment-statuses Add a custom status
The machine key (slug) is derived from the label server-side. A duplicate label answers 409.
/v1/appointment-statuses/{slug} Change a status
Label, colour, icon, order — and the busy flag, which cascades to every appointment already in the status and can therefore collide with double-booking protection, which is the correct answer rather than a bug.
/v1/appointment-statuses/{slug} Delete a custom status
Refused with a 409 for builtin statuses, statuses the settings mapping names, and statuses appointments still hold (the refusal carries the count). Deletion is audit-logged.
/v1/booking-forms Custom booking forms
/v1/booking-forms/{formId} One booking form
/v1/booking-forms Create a booking form
Fields and their conditional rules in one payload. Field keys are unique per TENANT, not per form — they name notification shortcodes and CSV columns, which are flat namespaces.
/v1/booking-forms/{formId} Replace a booking form
A form is saved WHOLE — fields and rules replaced in one transaction — because a rule names a field by key and a partial save could leave one pointing at nothing. THERE IS NO PATCH, deliberately: to change one field, READ the form first (`GET /v1/booking-forms/{formId}`), modify it, and PUT everything back — the flows surface’s read-modify-write discipline. Fields whose ids match stored ones are updated in place, so answers and translations survive.
/v1/booking-forms/{formId} Delete a booking form
/v1/offers Offers
Rewards the shop gives for a choice the customer can make: paying a deposit today, booking a named service, or spending at least an amount. Triggers combine with AND. Exactly ONE offer applies to a booking — the most valuable match the customer qualifies for — and it is composed into the appointment’s signed price lines by the server, never requested.
/v1/offers Create an offer
A free extra must name an add-on that belongs to the service being booked, and a free product is taken off the shelf when the booking is made — if none are left the offer simply does not apply, and the booking still succeeds at its ordinary price. A free add-on does NOT lengthen the appointment. `usageLimit` caps how many bookings may earn it in total and `oncePerCustomer` caps it per person; both are claimed inside the booking transaction, and an offer that has run out is never a refusal — the next-best match applies instead.
/v1/offers/{id} Replace an offer
PUT, not PATCH: the payload is the WHOLE offer, exactly as the panel saves it.
/v1/offers/{id} Delete an offer
New bookings stop earning it. Bookings that already earned it keep what they were given — the reward lives in their own price lines, not in this row.
Flows
The guided-booking wizard — cards, options, and the publish gate, for keys carrying the settings.manage scope. Publishing runs the same validator the panel’s editor runs, and a refusal returns the findings so an agent can repair the flow. The full lesson lives at /developers/flows.
/v1/flows Guided-booking flows
Every flow — published or draft — with its card count, its public `path` (`/flow/{business}/{slug}`, the address to hand out once published), and its live `issues`: the same validation the editor shows, recomputed on every read. An empty issues array means the flow can be published as it stands.
/v1/flows/{id} One flow, cards and all
The full document: `cards` (each with its `options`), `startCardId`, `published`, and the recomputed `issues` list. THIS IS THE VALIDATE STEP — after saving, read the flow back and check `issues` before publishing; entries with severity `error` will block the publish, `warning` entries never do. Each issue carries a machine `code`, a human `message`, and the `cardId`/`optionId` it is about.
/v1/flows Create a flow (always unpublished)
A flow is a small decision wizard shown at `/flow/{business}/{slug}`: cards ask a question, options answer it, and every route ends by handing the visitor to the booking page with a service preselected. It is a marketing layer only — a flow holds no price, no duration and no availability, and cannot change what anything costs. Creation takes just the address and name; add cards with `updateFlow`, then publish with `publishFlow`. A duplicate slug answers 409.
/v1/flows/{id} Save a flow — the whole card tree in one document
Sending `cards` REPLACES the stored tree: read the flow first, modify, send everything back. Ids are remapped on save — reuse a stored card id to keep that card, use any string as a client id for a new card, and reference either kind from `targetCardId` and `startCardId` in the same request. An option is one of two things: `action: "go_to_card"` with a `targetCardId` (navigation), or `action: "book"` with a `serviceId` and optional `staffId`/`locationId` (the end state — it becomes a link into the booking page with that service preselected). Drafts may be saved broken; editing a PUBLISHED flow re-runs the publish validation, and a refusal returns the findings in `error.details.issues`.
/v1/flows/{id} Delete a flow
Cards and options go with it. A published flow’s public page stops resolving immediately.
/v1/flows/{id}/publish Publish a flow to its public address
Runs the SAME validator the panel’s editor uses — a flow the editor would refuse is refused here identically. Errors that block publishing: a flow with no cards; no start card chosen (`no_start_card`) or a deleted one (`start_card_missing`); a card with no options (`empty_card` — a visitor could never leave it); a `go_to_card` option with no target (`option_missing_target`) or a target that no longer exists (`dangling_target`); a `book` option with no service (`option_missing_service`); and a flow where NO option books anything (`no_end_state`). Warnings never block: a card nothing links to (`unreachable_card`) and a loop (`cycle` — legal, a “start over” option is one). A refusal is 400 `validation_failed` with every finding in `error.details.issues`; fix the cards with `updateFlow` and publish again. On success the flow serves at its `path` immediately.
/v1/flows/{id}/unpublish Take a flow off its public address
The public page 404s from the next request; the document itself is kept and can be edited and republished. Never refuses for validation — a broken draft is legal, a broken LIVE flow is not.
Reports
Appointment and revenue figures over a date range, for keys carrying the reports.read scope. The same series the panel’s own reports draw.
/v1/reports/summary Appointments and revenue over a date range
The same series the panel’s revenue report draws: per-bucket appointment counts, booked and paid totals in minor units, plus window totals so the caller never re-adds a series the server just summed. Requires the plan to include reports.
Webhooks
Register an endpoint and choose topics. Each delivery is signed, retried with backoff when your endpoint is unhappy, and the endpoint is disabled automatically if it stays broken — a dead destination should not become a queue that never drains.
Verify the signature before you trust the body. It is an HMAC over the raw request bytes with your endpoint's secret, and comparing it is the only thing standing between your system and anyone who guesses the URL.
Topics (6)
-
booking.created -
booking.rescheduled -
booking.canceled -
booking.status_changed -
appointment.paid -
service.price_changed
Paging
List endpoints take limit and
offset. The default page is
25 and the largest is 100; asking for more is a
validation error rather than a silent truncation, so a page you receive is the page you
asked for.
Bookings can also be narrowed before paging — by customer, staff member, service, status,
and a from/to
window. Filtering is cheaper than walking pages, and a phone number is matched on its
digits, so formatting differences cannot hide a match.
curl "https://app.voltsbook.com/v1/bookings?from=2026-09-01T00:00:00Z&limit=50" \
-H "Authorization: Bearer $VOLTSBOOK_KEY" Rate limits
Each key gets its own budget — 120 requests a minute by default, and an operator can raise it on their own deployment. The budget is per KEY, not per address, so a busy integration behind one office connection is not throttled as though it were a single browser.
Over budget answers 429 with
RATE_LIMITED. Back off and retry rather than
hammering — and when the retry is a write, send the same
Idempotency-Key you sent the first time, so a
request that did land cannot land twice.
Errors
Every refusal carries a machine-readable code
beside its message. Branch on the code — the prose is written for a human reading a log
and may be improved at any time.
-
slot_taken409 - Somebody took that exact time between your availability read and your write. Ask for availability again and offer what is left; do not retry the same time. `details.emptyReason` is `all_booked`.
-
conflict (`details.reason: SLOT_UNAVAILABLE`)409 - The time was never bookable — the business is shut at that hour, closed that day, the booking is longer than any open day, or it falls inside the minimum-notice window. `details.emptyReason` says which (`outside_opening_hours`, `closed`, `duration_exceeds_opening_hours`, `too_soon`) and `details.emptyDetail` is one sentence you can relay. Re-reading availability around the same time will not help; move to a different day, or say so.
-
validation_failed400 - The request was refused on its content. `details` carries the repair: zod’s field errors for a malformed body (including the name of a field we do not recognise), `issues[]` naming each missing form answer, or `reason` naming the rule — `CUSTOMER_ADDRESS_REQUIRED` for a mobile service booked without an address, `NO_QUALIFIED_STAFF` when nobody is assigned to the service (`details.emptyReason` is `no_qualified_staff`, the same word `GET /v1/availability` uses).
-
conflict409 - A booking rule refused it — too many for that customer, that day or that window. `details.reason` is `LIMIT_REACHED` and `details` carries the rule’s window and ceiling.
-
feature_disabled403 - The business’s plan does not include this feature. Not a key problem — the same request would work on a plan that grants it.
-
insufficient_scope403 - The key is real but was minted without the scope this operation needs. `details.requiredScope` names it and `details.heldScopes` names what the key has.
-
caller_verification_required403 - Moving or cancelling somebody’s booking needs their phone number in the body. Ask the caller for it. A number that does not match answers `forbidden` instead.
-
not_found404 - No such service, staff member, reservation or booking for this business. Ids are per-business; a real id belonging to somebody else answers this too.
-
idempotency_key_reused409 - This `Idempotency-Key` was already used for a different body. Use a fresh key for a new intent, and the same one only to retry the same intent.
-
reservation_expired409 - The hold ran out before you turned it into a booking. Read availability again and hold a fresh slot.
-
rate_limited429 - Too many requests. Back off and retry; when the retry is a write, send the same `Idempotency-Key`.
A booking, end to end
Four calls take you from nothing to a confirmed appointment. Read the catalog, ask what is free, then either book straight away or hold the slot while your customer finishes deciding.
-
1 · Read the catalog
Services carry their duration and price; staff carry who performs what. You need a service id and, unless you are happy with first-available, a staff id.
curl https://app.voltsbook.com/v1/services \ -H "Authorization: Bearer $VOLTSBOOK_KEY" -
2 · Ask what is actually free
The grid answers with real start times — opening hours, the person's own schedule, holidays, blocked time and existing bookings all already applied. Never compute availability yourself from opening hours; a slot offered here is one the booking call will accept. Reading options aloud on a call? Add
perDay=4to get the first four starts of each day (totalSlotskeeps the real count). And an empty answer explains itself:emptyReasonsays whether the window is fully booked, closed, past the booking horizon, or can never fit the service at all, withnextAvailablecarrying the earliest later opening to counter-offer.curl "https://app.voltsbook.com/v1/availability?serviceId=$SERVICE&from=2026-09-01&to=2026-09-07&perDay=4" \ -H "Authorization: Bearer $VOLTSBOOK_KEY" -
3a · Book it
One call creates the customer if they are new, prices the appointment server-side, and books it. Send an
Idempotency-Key: a timeout you retry must not produce two appointments. If the service asks intake questions (itsformsonGET /v1/services), send the answers asformAnswerskeyed by field key — a missing required answer refuses the booking naming exactly which key, indetails.issues[]. The response'sbookingis the created appointment (bookings[]is the exhaustive array form).curl -X POST https://app.voltsbook.com/v1/bookings \ -H "Authorization: Bearer $VOLTSBOOK_KEY" \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{"serviceId":"…","staffId":"…","startAt":"2026-09-02T09:00:00Z", "customer":{"firstName":"Ada","lastName":"Lovelace","email":"ada@example.com"}}' -
3b · Or hold it first
If your customer still has a form to fill in, take a reservation: the slot is held briefly and released on its own if nobody confirms. A reservation is not an appointment — the business does not see a booking until you turn it into one.
curl -X POST https://app.voltsbook.com/v1/reservations \ -H "Authorization: Bearer $VOLTSBOOK_KEY" \ -H "Content-Type: application/json" \ -d '{"serviceId":"…","staffId":"…","startAt":"2026-09-02T09:00:00Z"}' -
4 · Handle the race honestly
Between step 2 and step 3 somebody else can take the slot — that is not a bug, it is what a shared diary is. You get
409 slot_taken: ask for availability again and offer the customer what is left, and do not retry the same time in a loop. A time that was never bookable at all is a different answer —details.reason: SLOT_UNAVAILABLEwith anemptyReasonofclosed,too_soonorduration_exceeds_opening_hours— and re-reading availability around that time will only find more of the same. Say whatemptyDetailsays and move to another day. Rescheduling answers the same two ways, for the same reasons — a race isslot_taken, a time that was never bookable isSLOT_UNAVAILABLE. Before 2026-08-28 it saidslot_takenfor both, which sent agents back to re-read availability for a slot that had never been on offer. Everything else your integration needs to hear about — a booking moved in the panel, a payment settling, a cancellation — arrives as a webhook rather than by polling.
Need a key?
Keys are created in your own settings, and the API is available on plans that include it. The free plan is enough to try everything else first.