Skip to content

Holds

A hold blocks a slot while the guest proves they are reachable. Unverified, it lives 5 minutes; once verified it lives until 12 minutes after it was created. Always count down from expiresAt. A studio has at most 3 unverified holds at a time (sandbox sites excepted); beyond that, 429 RATE_LIMIT_EXCEEDED.

Terminal window
curl -s -X POST https://api.mrcharles.app/api/v1/public/sites/studio-uno-madrid/holds \
-H 'Content-Type: application/json' \
-d '{
"roomId": "d9a36c09-d151-5d4a-a077-296293b07be4",
"date": "2026-10-08",
"startTime": "17:00",
"durationMinutes": 120,
"guestName": "Marta Ruiz",
"channel": "phone",
"contact": "+34600111222",
"locale": "es",
"idempotencyKey": "6f1c0e3a-6b0e-4f0c-9f1a-2a8f1b7c4d55",
"captchaToken": "0.AbC…"
}'
FieldTypeRequiredNotes
roomIduuidYesMust be bookable
datestringYesYYYY-MM-DD, studio time zone
startTimestringYesHH:MM, on the stepMinutes grid
durationMinutesnumberYesBetween minMinutes and maxMinutes, multiple of stepMinutes
guestNamestringYes2–120 characters
channelstringYesphone or email
contactstringYesE.164 phone (+34600111222) or an email address. Phones must be mobile numbers in a country we text (Spain today); anything else answers 422 PHONE_NOT_SUPPORTED, so offer email
localestring—es (default) · ca · en — language of the code message
idempotencyKeystringYesOne per attempt; reuse it when retrying the same request
captchaTokenstringconditionalRequired when the site payload has captchaSiteKey
groupSizeIdstring—Group size the guest picked, for rooms that price by group. Omitted = the room’s default
partySizenumber—How many people, for the studio’s information. 1 to the room’s capacity; never changes the price

No price is sent. The server computes it. Show it before the hold with /quote — the hold returns the same object, and its price is the one the guest pays.

{
"holdId": "b59b9109-1beb-44fa-a679-c2d612844c62",
"status": "pending",
"roomId": "d9a36c09-…",
"roomName": "Rehearsal B",
"date": "2026-10-08",
"startTime": "17:00",
"endTime": "19:00",
"timezone": "Europe/Madrid",
"price": {
"priceCents": 5000, "depositCents": 1500, "currency": "EUR", "minutes": 120,
"lines": [{ "kind": "time", "bandId": "standard", "label": "Alquiler de sala", "minutes": 120, "unitCents": 2500, "subtotalCents": 5000 }],
"pricingVersion": 0
},
"channel": "phone",
"contactMasked": "+34 ••• ••• 222",
"expiresAt": "2026-10-06T12:26:23+02:00",
"resendsLeft": 2,
"paymentModes": ["card", "on_site"],
"devCode": "181989"
}

price.depositCents is what a card payment charges now; priceCents is the full price. lines itemise it (the subtotals always add up to priceCents); show them as “How is this calculated?”. The price is locked when the hold is created: if it differs from a quote you showed earlier, tell the guest before the code step. contactMasked is safe to display. devCode exists only on sandbox sites — never on a live one.

Same key + same payload → the same hold (no second SMS). Same key + different payload → 400 VALIDATION_ERROR. After a hold expires, use a new key.

StatusCodeCause
400VALIDATION_ERRORBad duration, malformed contact, idempotency mismatch
400CAPTCHA_FAILEDMissing, reused or expired captcha token
400GROUP_SIZE_INVALIDgroupSizeId not offered by this room, or partySize outside 1…capacity
404SITE_NOT_LIVESite unpublished
409ROOM_NOT_BOOKABLEOnline booking off for this room
409OUTSIDE_HOURSThe room is closed at that time
409OUTSIDE_BOOKING_WINDOWToo soon (minNoticeMin) or too far (maxAdvanceDays)
409SLOT_TAKENSomeone else got there first
429RATE_LIMIT_EXCEEDED15/min per IP · 5/h per contact · 20/h per IP
503SMS_UNAVAILABLESMS delivery is down — offer channel: "email"

POST /public/sites/{slug}/rooms/{roomId}/quote {#quote}

Section titled “POST /public/sites/{slug}/rooms/{roomId}/quote {#quote}”

The exact price of a slot, computed by the server with the same rules as a hold. Read-only: it reserves nothing and does not check availability. Call it whenever the guest changes the date, start time or duration — never multiply hourlyCents yourself.

Terminal window
curl -s -X POST https://api.mrcharles.app/api/v1/public/sites/studio-uno-madrid/rooms/d9a36c09-d151-5d4a-a077-296293b07be4/quote \
-H 'Content-Type: application/json' \
-d '{ "date": "2026-10-08", "startTime": "17:00", "durationMinutes": 120, "locale": "es" }'
FieldTypeRequiredNotes
date, startTime, durationMinutes—YesSame rules as the hold
groupSizeId, partySize——Same as the hold
localestring—Language of lines[].label

Response 200: { roomId, date, startTime, endTime, timezone, price } — price is the same object the hold returns. Errors: VALIDATION_ERROR, GROUP_SIZE_INVALID, SITE_NOT_LIVE, ROOM_NOT_BOOKABLE. Rate limit: the read bucket, 120 requests per minute per IP, shared with the site payload and availability — debounce (~250 ms) and cache quotes by room, date, start, duration and group on your side, as the hosted screen does. Cache-Control: no-store.

Rounding. Prices are prorated to the minute and rounded once, half up, to the cent: 25 €/h for 50 minutes is 20,83 €.


{ "code": "181989" }

Returns the same hold shape with "status": "verified" and a refreshed expiresAt: 12 minutes after the hold was created, the time left to choose how to pay.

StatusCodeCause
400CODE_INVALIDWrong code, or older than 10 minutes
429TOO_MANY_ATTEMPTS5 wrong codes — the hold is burnt, start over
410HOLD_EXPIREDexpiresAt passed

No body. Sends a fresh code and resets the attempt counter.

  • Maximum 3 sends per hold (the first one included) — resendsLeft tells you how many are left.
  • At least 60 seconds between sends, otherwise 429 RATE_LIMIT_EXCEEDED.
POST /holds verify confirm
(none) ─────────────────▶ pending ──────────▶ verified ──────────▶ confirmed
│ │
5 wrong codes ────┤ ├── 12 min after creation ──▶ expired
5 min ─────┘

An expired or burnt hold frees the slot immediately. There is no “un-expire”: create a new hold.