MeetyyAPI
Documentation / API Reference / Booking

Booking API

Endpoints for the session booking lifecycle (Phase 3). Covers a mentee discovering follow-up slots and requesting a booking, viewing a booking's detail, paying for a paid session through Stripe Checkout, and a mentor managing the request through its lifecycle.

  • Base URL: /api/v1
  • Auth: all endpoints require a Sanctum bearer token — Authorization: Bearer {token}
  • Content type: application/json

Scope note: both free and paid session types are bookable. Free sessions skip straight to booked on confirmation; paid sessions settle through Stripe Checkout (see endpoint 5). The PAID transition is driven by Stripe's webhook, not by the buyer returning from the checkout page — so after a payment redirect, re-read the booking rather than assuming success. There is no client endpoint for a refund: refunds are a property of cancelling, decided by the cancellation policy (see Refunds), with an admin endpoint for the cases nothing can decide automatically. Payment-side detail lives in payments-api.md.


Response envelope

Every response uses the standard envelope.

Success

{
  "status": "success",
  "message": "…",
  "data": { }
}

Error

{
  "status": "error",
  "message": "…",
  "data": { }
}

Validation errors (422) return field errors in data:

{
  "status": "error",
  "message": "The primary slot id field is required.",
  "data": { "primary_slot_id": ["The primary slot id field is required."] }
}

Status codes

Code Meaning
200 OK (read, lifecycle action)
201 Booking created
401 Missing/invalid token
404 Not found or not owned by the caller
409 No seat left on the slot (lost a race, or the session filled up before confirmation)
422 Validation error / invalid state transition / outside follow-up window / slot already started
500 Server error
502 Payment gateway unreachable (checkout only)

Enums

Booking status

Value Label
requested Requested (unpaid — checkout open, or free and awaiting the mentor)
paid Paid (money in, awaiting the mentor's decision)
confirmed Confirmed (mentor accepted — the session is live)
completed Completed
cancelled Cancelled
refunded Refunded (the money has gone back)

Listed in lifecycle order, which is not the order they used to run in: payment now comes before the mentor's decision, so paid precedes confirmed.

Follow-up status: reserved · completed · cancelled · rescheduled

Slot status: available · held · booked · cancelled · vacation_blocked · override_blocked


Shared objects

Booking object

{
  "id": "9b6f…",
  "status": "requested",
  "statusLabel": "Requested",
  "isFree": true,
  "priceSnapshot": null,
  "mentorId": "8a1c…",
  "menteeId": "7d2e…",
  "mentor": { "…party object…" },
  "mentee": { "…party object…" },
  "sessionTypeId": "6c3f…",
  "sessionType": { "…session-type object…" },
  "meetingUrl": null,
  "mentorApprovedAt": null,
  "paidAt": null,
  "requiresPayment": false,
  "awaitingPayment": false,
  "awaitingMentorApproval": false,
  "mentorNote": null,
  "cancellationReason": null,
  "cancelledAt": null,
  "completedAt": null,
  "primarySlot": { "…slot object…" },
  "followups": [ { "…follow-up object…" } ],
  "createdAt": "2026-07-01T09:00:00+00:00"
}

The mentor variant of this object also includes "cancelledBy": "<userId|null>". mentor, mentee, sessionType, primarySlot, and followups are included only when loaded — the list, detail, create, and lifecycle responses each load a relevant subset (the detail endpoints load them all).

meetingUrl is gated on the session being live. It is non-null only at confirmed or completed — the same rule for free and paid alike, since a paid booking only reaches confirmed once its money is in. It is null at requested, at paid (money in, mentor has yet to accept), and after cancelled.

The room belongs to the slot, not to the booking, so every mentee on a group session (max_capacity > 1) gets the same meetingUrl. A mentee whose booking is not live yet sees null even when a co-tenant on the same slot already has the room open.

Reading the state. status alone is ambiguous at requested, which means "pay now" for a paid booking and "waiting on the mentor" for a free one. Use the flags:

Field Means
requiresPayment this booking has a real amount to charge (a "paid" type priced at 0.00 is free in effect)
awaitingPayment requested and requiresPayment — send the mentee to checkout
awaitingMentorApproval paid — money is in, the mentor has yet to accept
mentorApprovedAt when the mentor accepted; set on an in-chat appointment before payment
paidAt when the money landed; the mentor-response clock runs from here

Party object

Compact identity summary for the mentor or mentee on a booking.

{
  "profileId": "8a1c…",
  "userId": "3f9a…",
  "headline": "Senior Product Designer",
  "fullName": "Jane Mentor",
  "avatarUrl": "https://…",
  "timezone": "Asia/Dhaka"
}

Session-type object

What was booked, embedded so a booking row needs no second request.

{
  "id": "6c3f…",
  "title": "Career Coaching",
  "sessionModel": "regular_weekly",
  "sessionModelLabel": "Regular Weekly",
  "durationMinutes": 60,
  "isFree": false,
  "price": "90.00",
  "currency": "USD",
  "followUpCount": 2
}

Follow-up object

{
  "id": "5f0a…",
  "sequenceNumber": 1,
  "status": "reserved",
  "statusLabel": "Reserved",
  "rescheduleCount": 0,
  "slot": { "…slot object…" },
  "completedAt": null
}

Slot object

{
  "id": "1a2b…",
  "mentor_id": "8a1c…",
  "session_type_id": "6c3f…",
  "availability_rule_id": "4d5e…",
  "starts_at": "2026-07-08T09:00:00+00:00",
  "ends_at": "2026-07-08T10:00:00+00:00",
  "buffer_ends_at": "2026-07-08T10:15:00+00:00",
  "held_until": null,
  "status": "available",
  "status_label": "Available",
  "is_selectable": true,
  "max_capacity": 1,
  "current_capacity": 0,
  "available_seats": 1
}

Gate the booking button on is_selectable (seat-aware), not on status. A group slot with free seats reads available even while other mentees hold or occupy seats on it. Full field notes in availability-api.md.


Mentee endpoints

1. List my bookings

GET /api/v1/mentee/bookings

Returns the authenticated mentee's own bookings, newest first.

Query parameters

Param Type Required Notes
status string no One of the booking-status values above
per_page integer no 1–100 (default 15)

200 Response

{
  "status": "success",
  "message": "Bookings retrieved successfully.",
  "data": {
    "bookings": {
      "data": [ { "…booking object…" } ],
      "links": { "first": "…", "last": "…", "prev": null, "next": null },
      "meta": { "current_page": 1, "per_page": 15, "total": 3, "last_page": 1 }
    }
  }
}

Errors: 401 unauthenticated · 404 no mentee profile · 422 invalid status


2. Get my booking detail

GET /api/v1/mentee/bookings/{id}

Returns one of the mentee's own bookings with everything a detail screen needs — the mentor party, session type, primary slot, and follow-ups all loaded. Owner-scoped: a booking that isn't yours returns 404 (not 403), so ids don't leak.

Path parameters

Param Type Notes
id uuid Booking id — must belong to the authenticated mentee

200 Response

{
  "status": "success",
  "message": "Booking retrieved successfully.",
  "data": { "booking": { "…booking object (embeds mentor + sessionType)…" } }
}

Errors: 401 unauthenticated · 404 no mentee profile / not found / not the caller's booking


3. Get follow-up slots for a primary slot

GET /api/v1/mentee/bookings/follow-up-slots?primary_slot_id={slotId}

For a chosen primary slot, returns one window per required follow-up (= session type follow_up_count) with the bookable slots inside each window. Returns follow_up_count: 0 and an empty windows array when the session type has no follow-ups.

Query parameters

Param Type Required Notes
primary_slot_id uuid yes Must exist in availability_slots

200 Response

{
  "status": "success",
  "message": "Follow-up slots retrieved successfully.",
  "data": {
    "follow_up_count": 2,
    "windows": [
      {
        "sequence": 1,
        "interval_days": 7,
        "flexibility_days": 2,
        "earliest_at": "2026-07-13T09:00:00+00:00",
        "latest_at": "2026-07-17T09:00:00+00:00",
        "slots": [ { "…slot object…" } ]
      },
      {
        "sequence": 2,
        "interval_days": 7,
        "flexibility_days": 2,
        "earliest_at": "2026-07-20T09:00:00+00:00",
        "latest_at": "2026-07-24T09:00:00+00:00",
        "slots": [ { "…slot object…" } ]
      }
    ]
  }
}

Errors: 401 · 422 missing/unknown primary_slot_id, or a one-time session type with no follow-up interval configured.


4. Create a booking

POST /api/v1/mentee/bookings

Holds the primary slot plus all follow-up slots (see checkout_hold_minutes) and creates a REQUESTED booking with its reserved follow-ups. Exactly follow_up_count follow-up slots must be supplied, and each must fall inside its sequence window (see endpoint 3).

For a paid session type this response also carries the checkout to redirect to. The mentee pays before the mentor ever sees the booking.

Payload

Field Type Required Notes
primary_slot_id uuid yes Must exist and be available
follow_up_slot_ids uuid[] conditionally Exactly follow_up_count items, distinct, each available and in-window
{
  "primary_slot_id": "1a2b…",
  "follow_up_slot_ids": ["3c4d…", "5e6f…"]
}

201 Response

{
  "status": "success",
  "message": "Booking created successfully.",
  "data": {
    "booking": { "…booking object with primarySlot + followups…" },
    "payment_url": "https://checkout.stripe.com/c/pay/cs_test_…",
    "expires_at": "2026-07-01T09:35:00+00:00"
  }
}

payment_url and expires_at are null for a free booking — there is nothing to pay. For a paid one, send the mentee straight to payment_url; the seat is held only until expires_at, after which the booking is cancelled and the seat released.

Errors

Code When
422 Validation failure; wrong number of follow-ups; a follow-up outside its window; primary has no free seat; the mentee already has a booking on this slot; one-time session without a follow-up interval; the slot has already started
409 One of the requested slots lost its last seat to someone else first
502 Checkout could not be opened. The booking is rolled back, so the mentee can simply retry on the same slot
401 Unauthenticated

Both free and paid session types are accepted, with isFree / priceSnapshot snapshotted from the session type. A free booking goes straight to the mentor. A paid one stays invisible to them until its payment settles — see the lifecycle reference.

Past slots are refused. A slot whose starts_at is in the past can never be attended, so booking one returns 422"The selected session has already started." for the primary, "Follow-up #{n} has already started." for a follow-up. The booking screen only offers future slots; this catches a stale page or a cached slot id.

Group sessions. Booking claims one seat, so on a slot with max_capacity > 1 several mentees each get their own booking on the same slot and all of them join the same meetingUrl. One mentee cannot take two seats on the same slot — a second attempt returns 422 ("You already have a booking on this slot.") unless the first was cancelled. Cancelling frees exactly that mentee's seat and leaves co-tenants untouched.


5. Get a payment link (paid bookings)

GET /api/v1/mentee/bookings/{id}/payment-link

Retry path. Endpoint 4 already returns a checkout for a new paid booking; this re-opens one for a booking that has not been paid for yet — the mentee closed the tab, or the previous session lapsed. It returns the hosted Stripe Checkout URL; the client opens it in a browser or in-app browser, and Stripe redirects to APP_FRONTEND_URL/payments/success.

Calling this again on the same booking cancels the previous checkout before opening a new one, so a mentee who abandons the page and comes back cannot end up with two live sessions and be charged twice.

200 Response

{
  "status": "success",
  "message": "Payment link generated successfully.",
  "data": {
    "payment_url": "https://checkout.stripe.com/c/pay/cs_test_…",
    "expires_at": "2026-07-03T09:00:00+00:00"
  }
}

expires_at tracks the slot hold — the gateway stops accepting money at the same moment the seat is released to other mentees. Stripe only accepts an expiry between 30 minutes and 24 hours out, so a nearly-lapsed hold is clamped up into that window.

The redirect is not the settlement

The booking is settled by Stripe's webhook, not by the buyer arriving at the success URL. A mentee who pays and immediately closes the tab still gets their seat; a mentee who reaches the success URL without paying gets nothing.

After the redirect, re-read the booking (endpoint 2) instead of assuming success. The status flips requested → paid when the webhook lands — normally within seconds, but not guaranteed to beat the redirect.

What happens at Stripe Effect on the booking
Payment succeeds REQUESTED → PAID; the held seat becomes booked; the mentor is notified and now owes a decision. No meetingUrl yet — that comes when they accept
…on an in-chat appointment the mentor already accepted REQUESTED → CONFIRMED directly; meetingUrl issued
Checkout expires unpaid Booking CANCELLED, seat released, mentor never told
Card declined (payment_intent.payment_failed) Booking CANCELLED, seat released — the mentee must rebook, not retry this link
Refund confirmed Booking REFUNDED

Errors

Code When
422 Booking costs nothing; already paid for (This booking has already been paid for.); no longer payable; or the seat reservation has lapsed (Your reservation for this time has expired. Please book again.)
404 No mentee profile / not found / not the caller's booking
401 Unauthenticated
502 Stripe unreachable (Could not start checkout. Please try again.) — safe to retry

The capacity race. A group session's last seat can sell while the checkout page sits open, and Stripe has no view of the seat count — so the charge succeeds anyway. The booking is then cancelled, both parties are notified, and the payment is refunded automatically with reason capacity_conflict (falling back to the admin refund queue only if the gateway refuses). Surface this to the mentee as "we owe you a refund"not "you were not charged", because they were.

Payment statuses, webhook events, and the refund pipeline are documented in payments-api.md; the engine internals in payment-system.md.


6. Cancel my booking

PATCH /api/v1/mentee/bookings/{id}/cancel

Cancels the mentee's own active booking. Releases every held/booked slot back to available and marks follow-ups cancelled.

Refunds are automatic and decided by the cancellation policy. Cancelling a paid booking — paid for but not yet accepted by the mentor — refunds the mentee in full; the booking becomes cancelled and then refunded once the gateway confirms. Cancelling a confirmed booking is timed: more than cancellation_window_hours (48 by default) before the session start it refunds the charge less the cancellation fee (25% by default), and inside that window it refunds nothing. See Refunds.

Payload

Field Type Required Notes
reason string no Max 1000 chars
{ "reason": "Changed plans" }

200 Response

{
  "status": "success",
  "message": "Booking cancelled.",
  "data": { "booking": { "…booking object…" } }
}

Errors: 401 · 404 not found / not the caller's booking · 422 booking is no longer active (already completed/cancelled)


Mentor endpoints

7. Booking dashboard

GET /api/v1/mentor/bookings

Returns the authenticated mentor's bookings, newest first. Drive the Pending / Upcoming / Completed / Cancelled tabs with the status filter (requested,paid / confirmed / completed / cancelled,refunded).

Pending spans two statuses because a free booking arrives at requested while a paid one arrives at paid — the mentee has already been charged and is waiting on a decision. Use awaitingMentorApproval to flag the ones where money is at stake.

Unpaid paid-type bookings are never listed. They are checkouts in progress, not requests: absent here, 404 on detail, and impossible to confirm.

Query parameters

Param Type Required Notes
status string no Booking-status value. Accepts paid, confirmed,paid, or status[]=
per_page integer no 1–100 (default 15)

An unknown status is a 422, never a silently ignored filter — a silent empty list is indistinguishable from "no bookings" and is very hard to spot in a UI. The key on the error bag is status.0, because the filter is normalised to a list before it is validated.

200 Response

{
  "status": "success",
  "message": "Bookings retrieved successfully.",
  "data": {
    "bookings": {
      "data": [ { "…booking object (mentor variant, includes cancelledBy)…" } ],
      "links": { "first": "…", "last": "…", "prev": null, "next": null },
      "meta": {
        "current_page": 1, "per_page": 15, "total": 2, "last_page": 1,
        "status_counts": {
          "requested": 4, "confirmed": 7, "paid": 3,
          "completed": 31, "cancelled": 2, "refunded": 1
        }
      }
    }
  }
}

meta.status_counts

Every booking status, zero-filled, for the tab badges and the "N pending requests" line. Three things about it:

  • It ignores ?status=. Switching tab must not renumber the tabs, so the counts are always over the whole (visible) set.
  • It counts past per_page. That is the entire reason it exists — the list is paginated, so counting the returned rows is wrong for any mentor with real volume.
  • It respects the same visibility rule as the list. Unpaid paid-type checkouts are excluded from both, so the counts and the rows agree.

paid is the pay-first queue — charged, awaiting a mentor decision. The "N pending requests" line is requested + paid; requested alone under-reports it, and for a mentor who only sells paid sessions it reads zero for ever.

Note the casing: status_counts is snake_case because it sits in Laravel's own meta envelope next to per_page and current_page. The booking rows inside data remain camelCase.

Errors: 401 · 404 no mentor profile · 422 invalid status


8. Get my booking detail

GET /api/v1/mentor/bookings/{id}

Returns one of the mentor's own bookings with everything a detail screen needs — the mentee party, session type, primary slot, follow-ups, and cancelledBy all loaded. Owner-scoped: a booking that isn't yours returns 404 (not 403), as does one whose payment has not landed yet.

Path parameters

Param Type Notes
id uuid Booking id — must belong to the authenticated mentor

200 Response

{
  "status": "success",
  "message": "Booking retrieved successfully.",
  "data": { "booking": { "…booking object (mentor variant: embeds mentee + sessionType + cancelledBy)…" } }
}

Errors: 401 unauthenticated · 404 no mentor profile / not found / not the caller's booking


9. Confirm a booking

PATCH /api/v1/mentor/bookings/{id}/confirm

Accepts a booking → CONFIRMED, which is the single point at which a session goes live: meetingUrl is issued for free and paid alike.

Which state it is accepted from depends on price. A free booking is accepted out of requested and claims its seats here. A paid booking is accepted out of paid — it was charged at booking, so its seat was already claimed when the money landed and is deliberately not claimed again.

A paid booking that has not been paid for cannot be confirmed — it returns 422 ("This booking has not been paid for yet."), and in practice 404, since such bookings are not visible to the mentor at all.

Payload: none.

200 Response

{
  "status": "success",
  "message": "Booking confirmed.",
  "data": { "booking": { "…booking object…" } }
}

409 Response — the session filled up while the request was pending:

{
  "status": "error",
  "message": "This session is now fully booked. The request has been cancelled and the mentee has been notified so they can pick another time.",
  "data": { }
}

Handle the 409 as terminal. On a group slot another mentee can legitimately take the last seat before this one is claimed. Overbooking is refused rather than absorbed (§2.7). Under pay-first this race has largely moved to settlement time — where the losing mentee is refunded automatically — so a 409 here is now mostly a free-session concern.

The losing request is cancelled by the platform as part of this call — it does not stay in requested. Its seats, holds and follow-ups are released, and both parties are notified (cancelled with no actor, because neither party caused it). There is nothing for the mentor to retry or decline: drop the row from the pending list.

Past sessions cannot be confirmed. A request can outlive the session it is for, so the slot time is re-checked here as well as at booking. Confirming after starts_at returns 422"This session has already started and can no longer be confirmed."

Errors: 401 · 404 not the mentor's booking, or its payment has not landed · 409 slot filled up (request cancelled) · 422 booking is in the wrong state, has not been paid for, or the session has already started


10. Decline a booking

PATCH /api/v1/mentor/bookings/{id}/decline

Declines a pending booking → CANCELLED, releasing its held or claimed slots. Functionally a mentor cancel of a pending request.

Declining a paid booking refunds the mentee automatically. The booking becomes cancelled immediately and then refunded once the gateway confirms the money moved; the mentee is notified that funds arrive within 5 working days. The decline itself never waits on the gateway, so a Stripe outage cannot block the mentor.

Payload

Field Type Required Notes
reason string no Max 1000 chars

200 Response

{
  "status": "success",
  "message": "Booking declined.",
  "data": { "booking": { "…booking object…" } }
}

Errors: 401 · 404 · 422 booking is no longer active


11. Cancel a booking

PATCH /api/v1/mentor/bookings/{id}/cancel

Cancels an active booking the mentor owns (e.g. after confirming). Releases every held/booked slot and cancels follow-ups.

A mentor's cancellation always refunds the mentee in full, out of paid or out of confirmed, however close to the session it happens — the cancellation fee applies only to the mentee's own cancellations. See Refunds.

Payload

Field Type Required Notes
reason string no Max 1000 chars

200 Response

{
  "status": "success",
  "message": "Booking cancelled.",
  "data": { "booking": { "…booking object…" } }
}

Errors: 401 · 404 · 422 booking is no longer active


Lifecycle reference

Payment comes first. A paid booking is charged at creation and only reaches the mentor once the money has landed; they then accept it (the session goes live) or decline it (the mentee is refunded automatically). Free sessions have nothing to charge and go straight to the mentor as before.

Status Means Seat Meeting link Mentor sees it
requested checkout open, unpaid — or free and awaiting the mentor held only no free bookings only
paid money in, awaiting the mentor's decision claimed no yes
confirmed mentor accepted — the session is live claimed yes yes
completed session ended claimed yes yes
cancelled / refunded over released no see below

Free session

REQUESTED ──(mentor confirm)──► CONFIRMED ──(session ends, auto)──► COMPLETED
    │                               │
    │(mentor decline /              │(mentor/mentee cancel)
    │ mentee cancel /               ▼
    │ auto-expire) ─────────────► CANCELLED

Paid session

REQUESTED ─(Stripe webhook: paid)─► PAID ─(mentor confirm)─► CONFIRMED ─(ends, auto)─► COMPLETED
    │                                │                           │
    │(checkout expired /             │(decline / mentee cancel /  │(mentor/mentee cancel)
    │ mentee cancel)                 │ mentor no-response)        ▼
    ▼                                ▼                        CANCELLED  ← per policy:
CANCELLED  ← no money taken       CANCELLED ──(refund lands)──► REFUNDED    full, partial,
                                                                            or nothing

In-chat appointments are the one flow that cannot be pay-first: the mentor sets the price when accepting, so there is nothing to charge for until then. Accepting leaves the booking requested with mentorApprovedAt set — use the awaitingPayment flag, not status, to tell that state apart from an ordinary unpaid booking. When that payment settles it goes straight to confirmed, since the mentor has already agreed.

The REQUESTED → PAID edge is driven by the checkout.session.completed webhook, never by the client. REQUESTED → CANCELLED also fires from checkout.session.expired and payment_intent.payment_failed. CANCELLED → REFUNDED comes from charge.refunded once the gateway confirms the money moved.

Refunds

What a cancellation refunds is decided by CancellationPolicyService from three things: whether the session was ever agreed (confirmed), who is cancelling, and — for a mentee cancelling an agreed session only — how long until it starts.

Trigger Refund Recorded reason
Mentor declines a paid booking 100% mentor_declined
Mentor never answers (auto-expiry) 100% mentor_no_response
Mentee cancels a paid booking 100% requested_by_customer
Mentor cancels a confirmed booking 100%, however late mentor_cancelled
Mentee cancels a confirmed booking, before the cutoff 100% − cancellation fee (75% by default) mentee_cancelled
Mentee cancels a confirmed booking, inside the cutoff nothing — the mentor keeps the charge mentee_cancelled
Platform cancels a confirmed booking 100% platform_error
Checkout expired / never paid n/a — no money was taken
Group seat lost during checkout 100% capacity_conflict

The cutoff and the fee are admin-editable platform settings, not constants:

Setting Default Effect
cancellation_window_hours 48 Hours before the session start after which a mentee's cancellation is no longer refunded
cancellation_fee_percentage 25 Withheld from a mentee's cancellation made before that cutoff

A full refund marks the booking cancelled immediately and only refunded once the gateway confirms — so the mentee is never told funds are on the way when they are not. If the gateway refuses, the payment is parked in the admin refund queue (refund_required_at) instead. The mentee is told funds arrive within 5 working days.

A partial refund (the mentee's early cancellation) leaves the booking at cancelled and the payment at partially_refunded. It never becomes refunded, because part of the charge stayed with the mentor. The mentee still gets a RefundIssuedNotification, carrying the amount that actually went back and saying a cancellation fee was withheld.

Rounding always favours the mentee: the withheld share is floored, not the refunded one.

Refunds an admin issues by hand — a platform failure, or a disputed session — go through POST /api/v1/admin/transactions/{id}/refund. See payments-api.md.

Automated transitions (scheduled jobs, not API):

Job Schedule Effect
bookings:auto-complete every 30 min confirmed bookings (and reserved follow-ups) past their end time → completed; credits total_sessions
bookings:expire-unconfirmed every 15 min Three sweeps: free requested older than the response window → cancelled; paid unanswered (or whose session has started) → cancelled and refunded; unpaid requested with no live hold → cancelled, no refund
slots:release-expired-holds every minute Expired holds → slot back to available

Configuration (config/booking.php):

Key Env Default Notes
mentor_response_hours BOOKING_MENTOR_RESPONSE_HOURS 24 Measured from paidAt for a paid booking, createdAt for a free one
checkout_hold_minutes BOOKING_CHECKOUT_HOLD_MINUTES 35 Must exceed 32 — Stripe raises any checkout expiry to at least 31 minutes, so a shorter hold would release the seat mid-payment
payment_deadline_hours BOOKING_PAYMENT_DEADLINE_HOURS 24 In-chat appointments only
hold_expiry_warning_minutes BOOKING_HOLD_EXPIRY_WARNING_MINUTES 5 How early the mentee is warned

Holding a seat for 35 minutes rather than 10 reduces how many mentees can be in checkout for the same group slot at once. Stripe's 31-minute floor forces it; abandoned checkouts are released early by checkout.session.expired.

Who is told what. An unpaid paid-type booking is invisible to the mentor — it is a checkout in progress, not a request. It is absent from their dashboard, 404s on detail, cannot be confirmed, and its cancellation is never announced to them.