VoltsBook

/v1 API reference

The same pipeline, over HTTP.

A booking made here takes the same lock, the same in-transaction re-check and the same database constraint as one made on the booking page. There is no faster path that skips a rule.

Base URL

https://app.voltsbook.com/v1

Every request

Authorization: Bearer vb_live_…

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.

GET /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.

GET /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.

GET /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.

GET /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.

GET /v1/locations

Bookable locations

GET /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.

POST /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.

PATCH /v1/services/{id}

Change a service

Partial update — send only the fields to change.

DELETE /v1/services/{id}

Delete a service

POST /v1/services/{id}/extras

Add an extra to a service

PATCH /v1/services/{id}/extras/{extraId}

Change an extra

DELETE /v1/services/{id}/extras/{extraId}

Remove an extra

POST /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.

PUT /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.

DELETE /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.

GET /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.

POST /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`.

GET /v1/reservations/{id}

Inspect a hold

DELETE /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.

GET /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.

GET /v1/bookings/{id}

One booking

POST /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.

POST /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.

POST /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.

POST /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.

GET /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.

PATCH /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.

GET /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`.

POST /v1/appointment-statuses

Add a custom status

The machine key (slug) is derived from the label server-side. A duplicate label answers 409.

PATCH /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.

DELETE /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.

GET /v1/booking-forms

Custom booking forms

GET /v1/booking-forms/{formId}

One booking form

POST /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.

PUT /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.

DELETE /v1/booking-forms/{formId}

Delete a booking form

GET /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.

POST /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.

PUT /v1/offers/{id}

Replace an offer

PUT, not PATCH: the payload is the WHOLE offer, exactly as the panel saves it.

DELETE /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.

GET /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.

GET /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.

POST /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.

PUT /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`.

DELETE /v1/flows/{id}

Delete a flow

Cards and options go with it. A published flow’s public page stops resolving immediately.

POST /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.

POST /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.

GET /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_taken 409
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_failed 400
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).
conflict 409
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_disabled 403
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_scope 403
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_required 403
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_found 404
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_reused 409
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_expired 409
The hold ran out before you turned it into a booking. Read availability again and hold a fresh slot.
rate_limited 429
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. 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. 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=4 to get the first four starts of each day (totalSlots keeps the real count). And an empty answer explains itself: emptyReason says whether the window is fully booked, closed, past the booking horizon, or can never fit the service at all, with nextAvailable carrying 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"
  3. 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 (its forms on GET /v1/services), send the answers as formAnswers keyed by field key — a missing required answer refuses the booking naming exactly which key, in details.issues[]. The response's booking is 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"}}'
  4. 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"}'
  5. 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_UNAVAILABLE with an emptyReason of closed, too_soon or duration_exceeds_opening_hours — and re-reading availability around that time will only find more of the same. Say what emptyDetail says and move to another day. Rescheduling answers the same two ways, for the same reasons — a race is slot_taken, a time that was never bookable is SLOT_UNAVAILABLE. Before 2026-08-28 it said slot_taken for 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.

Take your first booking today.

Free plan, no card, and your trade already set up with real services and prices.

Start free