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.
POST /public/sites/{slug}/holds
Section titled “POST /public/sites/{slug}/holds”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…" }'| Field | Type | Required | Notes |
|---|---|---|---|
roomId | uuid | Yes | Must be bookable |
date | string | Yes | YYYY-MM-DD, studio time zone |
startTime | string | Yes | HH:MM, on the stepMinutes grid |
durationMinutes | number | Yes | Between minMinutes and maxMinutes, multiple of stepMinutes |
guestName | string | Yes | 2–120 characters |
channel | string | Yes | phone or email |
contact | string | Yes | E.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 |
locale | string | — | es (default) · ca · en — language of the code message |
idempotencyKey | string | Yes | One per attempt; reuse it when retrying the same request |
captchaToken | string | conditional | Required when the site payload has captchaSiteKey |
groupSizeId | string | — | Group size the guest picked, for rooms that price by group. Omitted = the room’s default |
partySize | number | — | 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.
Response 201
Section titled “Response 201”{ "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.
Idempotency
Section titled “Idempotency”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.
Errors
Section titled “Errors”| Status | Code | Cause |
|---|---|---|
| 400 | VALIDATION_ERROR | Bad duration, malformed contact, idempotency mismatch |
| 400 | CAPTCHA_FAILED | Missing, reused or expired captcha token |
| 400 | GROUP_SIZE_INVALID | groupSizeId not offered by this room, or partySize outside 1…capacity |
| 404 | SITE_NOT_LIVE | Site unpublished |
| 409 | ROOM_NOT_BOOKABLE | Online booking off for this room |
| 409 | OUTSIDE_HOURS | The room is closed at that time |
| 409 | OUTSIDE_BOOKING_WINDOW | Too soon (minNoticeMin) or too far (maxAdvanceDays) |
| 409 | SLOT_TAKEN | Someone else got there first |
| 429 | RATE_LIMIT_EXCEEDED | 15/min per IP · 5/h per contact · 20/h per IP |
| 503 | SMS_UNAVAILABLE | SMS 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.
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" }'| Field | Type | Required | Notes |
|---|---|---|---|
date, startTime, durationMinutes | — | Yes | Same rules as the hold |
groupSizeId, partySize | — | — | Same as the hold |
locale | string | — | 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 €.
POST /public/holds/{holdId}/verify
Section titled “POST /public/holds/{holdId}/verify”{ "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.
| Status | Code | Cause |
|---|---|---|
| 400 | CODE_INVALID | Wrong code, or older than 10 minutes |
| 429 | TOO_MANY_ATTEMPTS | 5 wrong codes — the hold is burnt, start over |
| 410 | HOLD_EXPIRED | expiresAt passed |
POST /public/holds/{holdId}/resend
Section titled “POST /public/holds/{holdId}/resend”No body. Sends a fresh code and resets the attempt counter.
- Maximum 3 sends per hold (the first one included) —
resendsLefttells you how many are left. - At least 60 seconds between sends, otherwise
429 RATE_LIMIT_EXCEEDED.
Lifecycle
Section titled “Lifecycle” 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.