Hourly spaces and services
Use this guide to sell time by the hour: meeting rooms, desks, studios, event spaces, or services such as a haircut or a massage.
The flow has four steps: slots → quote → hold → checkout. Steps 2 to 4 are the same calls you use for a hotel room. Only step 1 is new, and the quote has different inputs.
How an hourly space is different
Section titled “How an hourly space is different”- An hourly space has
vertical: "hourly"onGET /v1/resources/{id}. A hotel room has"lodging". - It has opening hours, durations (for example 60 minutes or 4 hours), a slot step (how often a slot can start), and optional buffers (free time before or after a booking).
- It has no room type and no rate plan. You price it per hour, and you can set a fixed price for a duration.
- One booking uses the whole space for its time.
capacityis the largest group in one booking. It is not the number of bookings at once. For ten desks, create ten spaces. - Times use the space’s time zone. If the space has none, it uses the tenant’s zone.
Your operator sets all of this up in the dashboard. The next section shows the API calls behind it, if you set up spaces from your own system.
Set up a space (operator, sk_ or dashboard)
Section titled “Set up a space (operator, sk_ or dashboard)”Create the space with its price and slot rules in one call. Either all of it is saved, or none of it.
curl -X POST https://api.<domain>/v1/resources \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "vertical": "hourly", "kind": "meeting_room", "name": "Ruang Rapat A", "capacity": 8, "currency": "IDR", "timezone": "Asia/Jakarta", "pricing": { "perHourMinorUnits": "150000" }, "slotRules": { "durations": [ { "minutes": 60, "fixedPriceMinorUnits": null }, { "minutes": 240, "fixedPriceMinorUnits": "500000" } ], "slotStepMinutes": 30, "bufferBeforeMinutes": 0, "bufferAfterMinutes": 15, "minNoticeMinutes": 60, "maxAdvanceDays": 90 } }'kind is one of meeting_room, desk, studio, event_space, service,
or other. It only changes the words the dashboard uses. If you leave out
vertical, the space gets the tenant’s default vertical. An hourly space
cannot have a roomTypeId. Leave timezone out, or send null, to use the
tenant’s zone. A name that is not an IANA zone gets 400 invalid_timezone.
These are the limits for slotRules. Defaults apply to what you leave out:
| Field | Allowed | Default |
|---|---|---|
durations | 1 to 12 items, each length once. minutes is 15 to 1440, in steps of 5. fixedPriceMinorUnits is a digit string, or null for the hourly price. | none (required) |
slotStepMinutes | 5 to 240, in steps of 5 | 30 |
bufferBeforeMinutes, bufferAfterMinutes | 0 to 240 | 0 |
minNoticeMinutes | 0 to 43200 | 60 |
maxAdvanceDays | 1 to 730 | 90 |
Money is a string of digits in the smallest currency unit, never a number.
"150000" is Rp 150.000 per hour.
Then set the opening hours. A space with no hours of its own uses the tenant’s hours. A day with no hours is closed.
curl -X PUT https://api.<domain>/v1/resources/$RESOURCE_ID/opening-hours \ -H "Authorization: Bearer sk_test_..." \ -H "Content-Type: application/json" \ -d '{ "weekly": { "mon": [{ "opens": "09:00", "closes": "17:00" }], "tue": [{ "opens": "09:00", "closes": "17:00" }], "wed": [{ "opens": "09:00", "closes": "12:00" }, { "opens": "13:00", "closes": "17:00" }], "thu": [{ "opens": "09:00", "closes": "17:00" }], "fri": [{ "opens": "09:00", "closes": "17:00" }], "sat": [], "sun": [] }, "exceptions": [ { "date": "2026-11-10", "closed": true, "intervals": [], "note": "Public holiday" } ] }'How the hours for one date are found, first match wins:
- an exception for this space on that date;
- an exception for the tenant on that date (a tenant holiday closes every space that has no exception of its own);
- the space’s own weekly hours, if it has any;
- the tenant’s weekly hours;
- nothing: closed.
Times are HH:MM in the space’s zone, in steps of 5 minutes. 24:00 is
allowed as a closing time. A booking never crosses midnight.
Other setup calls: GET/PUT /v1/resources/{id}/slot-rules,
GET/PUT /v1/resources/{id}/hourly-pricing, PATCH /v1/resources/{id}, and
GET/PUT /v1/tenant/opening-hours (dashboard session only). A pk_ key can
never call them. The PUT calls and PATCH need resources:write. The
GET calls need no scope, but they need an sk_ key.
PATCH /v1/resources/{id}takes at least one ofname,kind,capacity, andtimezone. Send"timezone": nullto go back to the tenant’s zone.verticalcannot be changed.PUT …/hourly-pricingtakes{ "perHourMinorUnits": "150000" }.- Send
"weekly": nulltoPUT …/opening-hoursto use the tenant’s hours. - Bad hours (not in order, overlapping, or not in 5-minute steps) return
400 invalid_hours. At most 4 open periods a day and 100 exceptions.
Step 1: get the slots
Section titled “Step 1: get the slots”Ask for one day and one duration. The date is a local date in the space’s zone.
curl "https://api.<domain>/v1/resources/$RESOURCE_ID/slots?date=2026-11-03&duration=60&party_size=4" \ -H "Authorization: Bearer pk_test_..."const { data } = await client.GET("/v1/resources/{id}/slots", { params: { path: { id: resourceId }, query: { date: "2026-11-03", duration: 60, party_size: 4 }, },});{ "resourceId": "0192f0c4-7d2e-7a10-9a51-3f6f2b1c9e01", "date": "2026-11-03", "timezone": "Asia/Jakarta", "durationMinutes": 60, "partySize": 4, "currency": "IDR", "slots": [ { "startsAt": "2026-11-03T02:00:00.000Z", "endsAt": "2026-11-03T03:00:00.000Z", "localStartTime": "09:00", "localEndTime": "10:00", "priceHintMinorUnits": "150000" } ], "reasons": []}startsAtandendsAtare exact instants. Use them in step 2.localStartTimeandlocalEndTimeare what you show the guest.priceHintMinorUnitsis a hint. The quote is the real price.- A
pk_key can call this endpoint. The answer has no guest data. - Fetch one day at a time. Do not load a whole month.
duration must be one of the space’s durations. If not, you get
400 invalid_duration. Read the allowed values from
slotConfig.durationsMinutes on GET /v1/resources/{id}. If the resource is
not an hourly space, you get 400 not_hourly.
When slots is empty
Section titled “When slots is empty”reasons tells you why. The list is empty when there are slots.
| Reason | Meaning |
|---|---|
outside_opening_hours | The space is closed that day, or the duration does not fit in any open period. |
min_notice | Every slot starts too soon (see minNoticeMinutes). |
max_advance | The date is too far ahead (see maxAdvanceDays). |
occupancy_exceeded | party_size is more than the space’s capacity. |
reserved | Every slot overlaps another booking. |
buffer_conflict | A slot does not overlap a booking, but it is too close to one. |
external_block | Every slot overlaps a block from a connected calendar. |
Buffers
Section titled “Buffers”A buffer is free time kept before and after each booking. The rule works in both directions. Say a booking runs 10:00 to 11:00 and the space has a 15-minute buffer after. A slot at 11:00 is not offered. A slot at 11:15 is.
Step 2: get a quote
Section titled “Step 2: get a quote”Send the exact startsAt and endsAt of the slot. Leave ratePlanId out.
curl -X POST https://api.<domain>/v1/quotes \ -H "Authorization: Bearer pk_test_..." \ -H "Content-Type: application/json" \ -d '{ "resourceId": "0192f0c4-7d2e-7a10-9a51-3f6f2b1c9e01", "from": "2026-11-03T02:00:00.000Z", "to": "2026-11-03T03:00:00.000Z", "partySize": 4 }'The quote checks the window again. The slot list is only a guide. A window
that is not one of the slots is refused with 409 unavailable and the same
reasons as above. A window whose length is not one of the space’s
durations is refused with 400 invalid_duration.
The price is in priceBreakdown. It has one line (other fields trimmed; see
Quotes for the full shape):
{ "priceBreakdown": { "unit": "hour", "lines": [ { "startsAt": "2026-11-03T02:00:00.000Z", "endsAt": "2026-11-03T03:00:00.000Z", "unit": "hour", "durationMinutes": 60, "unitAmountMinorUnits": "150000", "baseAmountMinorUnits": "150000", "seasonalMultiplierBps": 10000, "dayOfWeekMultiplierBps": 10000, "amountMinorUnits": "150000" } ], "nights": [], "totalMinorUnits": "150000", "currency": "IDR" }}How the price is made:
- Per hour: the hourly price times the minutes, divided by 60, rounded
half up. 90 minutes at 150000 per hour is 225000.
unitishour. - Fixed price: if the duration has a fixed price, that price is used
instead.
unitisslot. - Seasonal and day-of-week price rules still apply. The weekday is the local weekday of the start time.
- Length-of-stay, last-minute, and early-bird rules do not apply to hourly spaces.
The quote is valid for 15 minutes. If the space has no price, you get
422 no_pricing_rule.
priceBreakdown.lines is the same shape for hotel stays (one night line
per night). nights[] stays for hotel stays and is empty for hourly spaces.
Read lines in new code.
Step 3: hold the slot
Section titled “Step 3: hold the slot”Like a hotel room: send the signedQuoteToken with an Idempotency-Key. For
an hourly space, also send the guest’s contact in guest.
curl -X POST https://api.<domain>/v1/holds \ -H "Authorization: Bearer pk_test_..." \ -H "Idempotency-Key: $(uuidgen)" \ -H "Content-Type: application/json" \ -d '{ "signedQuoteToken": "eyJhbGciOi...", "partySize": 4, "guest": { "name": "Sari Dewi", "email": "sari@example.com", "phone": "+62 812 3456 7890" } }'About guest:
nameis 1 to 120 characters.emailis a valid email, up to 254 characters. We trim it and make it lowercase.phoneis optional: 6 to 20 characters, digits and spaces, with an optional+at the start.- With a
pk_key,guestis required for an hourly space. Without it you get400 guest_required. This way a booking you confirm as paid offline always has someone you can contact. - It is optional for hotels, and optional when you hold with an
sk_key. - We save it on the booking. You see it in the dashboard and in
GET /v1/bookings/{id}(guestEmail,guestName,guestPhone). We never return the guest’s name, email, or phone in a response to apk_key. - The email is not checked at this step. If you run the email code step later, the verified email replaces this one. Stripe also shows this email on the checkout page, so the guest does not type it again.
If someone else took the time in the meantime, or the buffer rule would be
broken, you get 409 hold_conflict. Show the guest the slots again.
Two guests can never hold overlapping times. The database refuses it.
Step 4: check out
Section titled “Step 4: check out”Unchanged from a hotel booking. Create the checkout session and send the guest to Stripe:
POST /v1/holds/{id}/checkout-session. See Embed the widget.- Optional: verify the guest’s email first with
POST /v1/holds/{id}/otpandPOST /v1/holds/{id}/otp/verify. You need this only if you confirm with a guest token. The widget does not do it. See Guest verification and confirm.
The hold lasts 15 minutes. The booking is confirmed when the payment succeeds.
If the guest pays the operator in cash or by bank transfer, the operator (or
your server, with an sk_ key that has bookings:write) confirms the hold in
that time with POST /v1/bookings/{id}/confirm and { "paidOffline": true }.
The contact you saved in guest is how the operator reaches the guest.
When a booking becomes confirmed, we email the guest a “Booking confirmed”
message with the space, the date and time in the space’s time zone, the number
of people, and the total. The same email goes out when an operator confirms a
booking as paid offline. You do not need to send it yourself.
Use the widget
Section titled “Use the widget”You do not need to build the steps above if you use the widget. Paste the same embed snippet you use for hotels:
<script async src="https://docs.booking-engine.davidvalentino.com/widget/v1.js"></script><div data-booking-widget data-key="pk_live_..." data-resource="<SPACE_ID>" data-api-base="https://<your-api-host>"></div>The widget reads the space and sees that it is an hourly space. There is no extra option. It then shows these steps:
- When. The guest picks how long (for example 1 hour), a day, and a start time. Each time is shown in the space’s own time zone. A day with no times left is marked “Full” or “Closed” once the guest opens it. The guest also enters how many people come. If the number is above the space’s capacity, the widget asks for a smaller group.
- Review. The guest sees the price from the quote, for example
“1 hour × IDR 150,000 / hour”. The guest also types a name, an email, and
an optional phone or WhatsApp number. The widget sends them as
guestwith the hold. - Pay. The widget holds the time and shows a 15-minute timer. “Pay now” sends the guest to Stripe.
If someone else takes the time first, the widget goes back to the time list with a short message and loads the times again. If your site sends too many requests, the widget shows “Too many requests — try again in N s” and waits.
The widget speaks English and Indonesian. Set data-lang="id" for Indonesian.
It only uses your publishable key. Hotels and hourly spaces work in the same
snippet, and the widget picks the right steps for each resource.
Time zones
Section titled “Time zones”datein the slots call is a local date in the space’s zone.- All other times in the API are UTC instants (
...Z). - On a day when the clocks change, a local time that does not exist is skipped. A local time that happens twice is offered once, at its first moment.
Hotels are unchanged
Section titled “Hotels are unchanged”Nights, room types, rate plans, restrictions, bulk rates, and the calendar
work as before. One tenant can sell both. Each space has its own vertical,
and it cannot be changed after the space is created.
Not supported yet
Section titled “Not supported yet”- Recurring bookings and bookings of more than one space at once.
- Several guests sharing one slot (group seats).
- Time-of-day prices (for example peak hours).
- Opening hours that run past midnight.
- Changing a booking. Cancel it and book again.
Next steps
Section titled “Next steps”- Quotes and Availability
- Make a booking from your server
- Holds and confirmation — including how to confirm a booking that was paid offline
- Webhooks
- Errors