Holds and idempotency

A hold keeps a place while a guest decides or pays. Pass hold_minutes to bookings_create and the booking comes back held with an expires_at; bookings_confirm makes it confirmed and bookings_cancel frees it. Pass an idempotency_key, a UUID you generate, so a retry returns the same booking instead of booking twice.

Plain text

States

heldThe place is kept until expires_at, and counts against capacity until then.
confirmedBooked. Without hold_minutes, bookings_create confirms at once.
cancelledThe place is free again. A payment taken online is refunded in full, unless bookings_cancel's refund says a part or none.

A hold past expires_at stays held but no longer takes a place: there's no expired status. Each change writes booking.held, booking.confirmed, booking.moved or booking.cancelled; a hold keeps its expires_at when it moves.

Hold a place

Pass hold_minutes, 5 to 1440. The booking comes back held, with its expires_at.

curl
curl -X POST https://daybooked.io/api/v1/bookings \
  -H "Authorization: Bearer daybooked_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "offering_id": "8f1c2e4a-0b7d-4c55-9a7e-3f2d1c0b9a88",
    "starts_at": "2026-12-05T15:00:00Z",
    "quantity": 2,
    "customer": {
      "email": "pat@example.com",
      "name": "Pat"
    },
    "hold_minutes": 30,
    "idempotency_key": "5f0f0a52-3a2e-4f1c-9a53-0d8a1e9e7b11"
  }'
201response
{
  "id": "1e2d3c4b-5a69-4788-9a6b-5c4d3e2f1a0b",
  "reference": "0GWYRZ2V",
  "status": "held",
  "expires_at": "2026-12-01T17:34:11+00:00",
  "starts_at": "2026-12-05T15:00:00+00:00",
  "quantity": 2,
  "total_cents": 7000,
  "idempotency_key": "5f0f0a52-3a2e-4f1c-9a53-0d8a1e9e7b11"
}

The widget holds a place for 35 minutes while a guest pays on Stripe Checkout, and the payment confirms it. That guest has passed bot checks and is paying, so the hold counts against capacity only.

A guest who leaves Checkout without paying can hear from you once: from 10 minutes to a day after the hold lapses, when Stripe says the Checkout expired unpaid, bookings_nudge emails them a link to finish booking, while the time is still free. The Reminders automation sends it on its own when its Unpaid holds switch is on.

Confirm or cancel

Confirm before expires_at. Confirming twice returns the booking unchanged; past expires_at, a hold confirms only if its place is still free.

curl
curl -X POST https://daybooked.io/api/v1/bookings/1e2d3c4b-5a69-4788-9a6b-5c4d3e2f1a0b/confirm \
  -H "Authorization: Bearer daybooked_live_…"
200response
{
  "id": "1e2d3c4b-5a69-4788-9a6b-5c4d3e2f1a0b",
  "reference": "0GWYRZ2V",
  "status": "confirmed",
  "expires_at": "2026-12-01T17:34:11+00:00",
  "starts_at": "2026-12-05T15:00:00+00:00",
  "quantity": 2,
  "total_cents": 7000,
  "idempotency_key": "5f0f0a52-3a2e-4f1c-9a53-0d8a1e9e7b11"
}

bookings_cancel frees the place of a hold or a booking. With refund: "none" keeps a payment made online, { amount_cents } refunds that part (refund_cents keeps it). Cancelling twice changes nothing. To call off a whole time and let its guests pick another, sessions_cancel takes guests: "choose" (below).

Deposits

An offering can ask guests who pay online at booking for a deposit to hold their place: deposit_cents, an amount, or deposit_percent, 1 to 99 of the total, on offerings_create or offerings_update. One or the other: setting one clears the other, and both at once is 400 deposit_conflict. Without one, guests pay the whole total.

  • Checkout takes the deposit of the total after any promo code: a percent rounds half up to a whole minor unit, an amount is at most the total. The booking keeps what was paid (paid_cents) and what Checkout charged (charged_cents); the rest is due at the start, a rental's at pick-up, as the guest's page, the emails and your dashboard say.
  • bookings_set_paid with paid: true records the rest paid by hand: paid_cents becomes the total. paid: false takes back only that rest, never the deposit.
  • Cancelling refunds what was paid online, the deposit: bookings_cancel's refund takes a part up to charged_cents (more is 400 refund_too_large), and a guest's own cancel or a time called off refunds all of it.
  • A deposit applies where guests pay online at booking (Stripe connected, on a paid plan): the booking page, the widget and an assistant's hold, through the same Checkout. A reseller's guests pay the reseller.

Gift cards

A business sells gift cards on its booking page (Settings → Gift cards, while it takes payments at booking) or issues them with gift_cards_issue, on any plan. A guest spends one by giving its code as gift_card_code to bookings_create, beside any promo_code.

  • A card's code is 12 characters of Crockford base32, read in any case, spaced or not. What's on it is the sum of its entries, which gift_cards_get lists (purchase or issue, redeem, refund, adjust): balance is that sum, available what it can spend now. Cards never expire.
  • A card pays what's due at booking after the promo code: the offering's deposit where it asks one, else the whole total, all it can spend at most. The booking keeps gift_card_id and gift_cents, which count as paid.
  • The card is taken when the booking is confirmed: at once when nothing is left to pay, or once the rest is paid on Checkout and the hold confirmed. A hold keeps its part from other bookings while it lasts, and one that lapses never takes it. A resent idempotency_key never takes it twice.
  • Cancelling a booking a card paid for puts its part back on the card, whoever cancels and whatever refund says about a payment made online.
  • gift_cards_adjust fixes a balance by a signed amount with a reason, never below zero. Nothing deletes a card. A reseller's bookings and an assistant's holds take no codes.

Calling off a time

sessions_cancel cancels a time and refunds everyone booked on it. Pass guests: "choose" to let each guest pick another time of the same offering instead, and note for a line in your words in their email.

  • Holds and a reseller's bookings are cancelled as before. Each other booking stays confirmed, keeps its reference and payment, and gets choose_by: the end of the 7th day after, in your time zone.
  • Each logs booking.called_off, and its guest gets one email with your note. On their booking page they pick another time with room for their party (booking.moved), or take the refund.
  • Whoever hasn't picked by choose_by is cancelled (booking.cancelled): what they paid online is refunded in full; money you took by hand is yours to give back. You can move or cancel any of them yourself meanwhile.
  • To end the wait sooner, sessions_cancel again with guests: "refund" cancels whoever is still picking; once more changes nothing.
  • A booking waiting on a pick holds no place, and gets no reminders.

Idempotency keys

  • Generate a UUID per booking and send it as idempotency_key.
  • A retry with the same key and the same request returns the original booking, even if the slot has filled since, and writes no second event.
  • The same key with a different request is refused with idempotency_mismatch.
  • The request is the offering, session (a course's series_id), start, quantity, periods, customer, hold_minutes, promo_code and gift_card_code; names, phones, notes and answers don't count.

Hold limits

Open holds made over the API and MCP are capped, so no agent or script can sit on your inventory. Past a cap, a hold is refused with too_many_holds:

  • 3 open holds per guest email
  • 20 open holds per API key or OAuth client
  • Half of any one slot's capacity, rounded up

Errors

Errors come back as JSON with the HTTP status; MCP tools return them as code: message.

too_many_holds429From bookings_create. The hold would pass a cap. Confirm or cancel one first, or book outright.
idempotency_mismatch409From bookings_create. The key was already used for a different booking.
sold_out409From bookings_create. Not enough left at that time.
answers_required400From bookings_create. The offering asks a required question (offerings_get's questions) and answers has nothing for it; question_id names it.
invalid_answer400From bookings_create. An answer doesn't fit its question: a choice that isn't one of its options, say; question_id names it.
code_invalid400From bookings_create. The promo_code isn't one of yours, is switched off, hasn't started yet or is for another offering.
code_expired400From bookings_create. The promo_code's end has passed.
code_used_up400From bookings_create. The promo_code has been used as often as its limit allows.
gift_card_invalid400From bookings_create. The gift_card_code isn't one of your cards, or was made in another currency.
gift_card_empty400From bookings_create. The gift card has nothing left on it.
gift_card_short409From bookings_create. The gift card's balance moved since it was checked: another booking spent it first. bookings_confirm says the same for a hold whose card was spent since.
plan_limit402From bookings_create. The business has had its plan's bookings this month: new ones, holds included, pause until it changes plan (upgrade_url) or the month turns.
hold_expired409From bookings_confirm. The hold ran past expires_at and its place was taken.
booking_cancelled409From bookings_confirm. The booking was cancelled, so it can't be confirmed.
409response
{
  "error": {
    "code": "hold_expired",
    "message": "This hold expired and its place was taken"
  }
}

Waitlist

A full time can keep a waitlist. Switch it on per offering with offerings_update and waitlist_enabled: times on a calendar only, a class's or an appointment's on its calendar.

  • On the booking page and the widget, a time with no room for the guest's party stays visible and offers the waitlist: they give their name, email and party, and nothing is held or paid. They get one email saying so. Every waitlist email carries their link to leave while they're on it.
  • When a place opens (a cancellation, a hold that lapsed, more capacity), daybooked's regular check emails everyone waiting whose party fits, all at once, and the first to book gets it: nothing is held for anyone. A guest hears at most once every 6 hours and 3 times in all, never inside the offering's notice or after the time starts.
  • A confirmed booking for the time, or one moved into it, takes a guest off its waitlist; a hold doesn't. A cancelled time ends its waitlist and emails each guest, whether or not the switch is still on. A change to a calendar entry leaves a time someone waits for as it is, as it leaves a booked one, and nobody is emailed. Switching the waitlist off with offerings_update takes everyone waiting off it and emails each of them a link to book another time.
  • When Fill the day opens the next day's time for a full one, or one with people waiting, everyone waiting whose party fits is emailed the new time with a link to book it, and stays on the waitlist they joined. waitlist_offer does the same for any other time of the offering, once per guest and time.
  • One inbox gets at most 10 waitlist emails a day from every business together, however its address is spelled. Joining from the booking page allows 10 joins a day per inbox and 40 per IP address, with each business.
  • waitlist_list shows who is waiting, for a session_id or an offering_id's times to come. waitlist_add puts a guest on by hand and emails them (409 has_room when their party fits now: book it instead; 400 too_many_places for a party the time could never take), and says whether the email went; waitlist_remove takes one off. Joining again from the booking page changes nothing; waitlist_add changes a guest's party.
  • waitlist.joined, waitlist.left and waitlist.opened carry ids and counts, never names.

Courses

A class can sell as a course: one booking takes a seat in every time of one calendar entry, from its first to come, at the class's price per seat for the whole course. Switch it on with offerings_update and course: true (a class only: 409 not_a_class), or Sell as a course in the offering's rules.

  • A course sells each calendar entry that ends: one that doesn't repeat, or a repeat with an end date (ends_on on series_create and series_update, which any repeating entry may now have). A course's repeating entry without one is refused with 409 invalid_entry.
  • availability_get gives one slot per entry with times to come, whatever dates are asked: series_id, starts_at (its first time to come), ends_at (its last time's end), sessions (how many), repeat, and remaining, the seats left in its fullest time.
  • bookings_create takes the slot's series_id (400 series_required without it). The booking holds a seat in every scheduled time of the entry from its first to come, all checked at once: one seat short in any of them refuses the whole booking with sold_out. It comes back with series_id, and session_id is its first time. An idempotency_key returns the same booking on a retry, as for any booking.
  • A course booking can't move (bookings_move answers 409 course_booking) and its guest can't change its time. Within your cancellation window they can cancel it, which cancels the whole course and refunds what was paid online.
  • Cancelling one time of a course with sessions_cancel leaves its course bookings standing on the rest, with no refund in v1: each logs booking.session_cancelled, and its guest gets one email naming the date. Removing the entry, or deleting the offering, with its bookings cancels each course booking whole, refunded as any cancel.
  • A course has no waitlist (409 waitlist_conflict). Its guests are on each of its times' rosters, marked course, and every time counts their seats. Reminders go for a course booking's first time only, Calendar feeds show a course booking at its first time, and resellers over OCTO don't see courses yet.