Holds
A hold blocks a slot while the guest proves they are reachable. It lives 12 minutes; the code inside it lives 10.
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 | ✅ | Must be bookable |
date | string | ✅ | YYYY-MM-DD, studio time zone |
startTime | string | ✅ | HH:MM, on the stepMinutes grid |
durationMinutes | number | ✅ | Between minMinutes and maxMinutes, multiple of stepMinutes |
guestName | string | ✅ | 2–120 characters |
channel | string | ✅ | phone or email |
contact | string | ✅ | E.164 phone (+34600111222) or an email address |
locale | string | — | es (default) · ca · en — language of the code message |
idempotencyKey | string | ✅ | One per attempt; reuse it when retrying the same request |
captchaToken | string | conditional | Required when the site payload has captchaSiteKey |
No price is sent. The server computes it.
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 }, "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. contactMasked is safe to display. devCode exists only in pre-production without a delivery provider — never in production.
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 |
| 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/holds/{holdId}/verify
Section titled “POST /public/holds/{holdId}/verify”{ "code": "181989" }Returns the same hold shape with "status": "verified" and a refreshed expiresAt — the guest now has another 12 minutes 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 | The 12 minutes ran out |
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 ────▶ expired 12 min ─────┘An expired or burnt hold frees the slot immediately. There is no “un-expire”: create a new hold.