Skip to content

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.

  • An hourly space has vertical: "hourly" on GET /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. capacity is 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.

Terminal window
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:

FieldAllowedDefault
durations1 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)
slotStepMinutes5 to 240, in steps of 530
bufferBeforeMinutes, bufferAfterMinutes0 to 2400
minNoticeMinutes0 to 4320060
maxAdvanceDays1 to 73090

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.

Terminal window
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:

  1. an exception for this space on that date;
  2. an exception for the tenant on that date (a tenant holiday closes every space that has no exception of its own);
  3. the space’s own weekly hours, if it has any;
  4. the tenant’s weekly hours;
  5. 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 of name, kind, capacity, and timezone. Send "timezone": null to go back to the tenant’s zone. vertical cannot be changed.
  • PUT …/hourly-pricing takes { "perHourMinorUnits": "150000" }.
  • Send "weekly": null to PUT …/opening-hours to 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.

Ask for one day and one duration. The date is a local date in the space’s zone.

Terminal window
curl "https://api.<domain>/v1/resources/$RESOURCE_ID/slots?date=2026-11-03&duration=60&party_size=4" \
-H "Authorization: Bearer pk_test_..."
{
"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": []
}
  • startsAt and endsAt are exact instants. Use them in step 2.
  • localStartTime and localEndTime are what you show the guest.
  • priceHintMinorUnits is 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.

reasons tells you why. The list is empty when there are slots.

ReasonMeaning
outside_opening_hoursThe space is closed that day, or the duration does not fit in any open period.
min_noticeEvery slot starts too soon (see minNoticeMinutes).
max_advanceThe date is too far ahead (see maxAdvanceDays).
occupancy_exceededparty_size is more than the space’s capacity.
reservedEvery slot overlaps another booking.
buffer_conflictA slot does not overlap a booking, but it is too close to one.
external_blockEvery slot overlaps a block from a connected calendar.

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.

Send the exact startsAt and endsAt of the slot. Leave ratePlanId out.

Terminal window
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. unit is hour.
  • Fixed price: if the duration has a fixed price, that price is used instead. unit is slot.
  • 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.

Like a hotel room: send the signedQuoteToken with an Idempotency-Key. For an hourly space, also send the guest’s contact in guest.

Terminal window
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:

  • name is 1 to 120 characters. email is a valid email, up to 254 characters. We trim it and make it lowercase. phone is optional: 6 to 20 characters, digits and spaces, with an optional + at the start.
  • With a pk_ key, guest is required for an hourly space. Without it you get 400 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 a pk_ 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.

Unchanged from a hotel booking. Create the checkout session and send the guest to Stripe:

  1. POST /v1/holds/{id}/checkout-session. See Embed the widget.
  2. Optional: verify the guest’s email first with POST /v1/holds/{id}/otp and POST /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.

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:

  1. 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.
  2. 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 guest with the hold.
  3. 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.

  • date in 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.

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.

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