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
PAIDtransition 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
paidprecedesconfirmed.
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, andfollowupsare included only when loaded — the list, detail, create, and lifecycle responses each load a relevant subset (the detail endpoints load them all).
meetingUrlis gated on the session being live. It is non-null only atconfirmedorcompleted— the same rule for free and paid alike, since a paid booking only reachesconfirmedonce its money is in. It isnullatrequested, atpaid(money in, mentor has yet to accept), and aftercancelled.The room belongs to the slot, not to the booking, so every mentee on a group session (
max_capacity > 1) gets the samemeetingUrl. A mentee whose booking is not live yet seesnulleven when a co-tenant on the same slot already has the room open.
Reading the state.
statusalone is ambiguous atrequested, which means "pay now" for a paid booking and "waiting on the mentor" for a free one. Use the flags:
Field Means requiresPaymentthis booking has a real amount to charge (a "paid" type priced at 0.00 is free in effect) awaitingPaymentrequestedandrequiresPayment— send the mentee to checkoutawaitingMentorApprovalpaid— money is in, the mentor has yet to acceptmentorApprovedAtwhen the mentor accepted; set on an in-chat appointment before payment paidAtwhen 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 onstatus. A group slot with free seats readsavailableeven 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/priceSnapshotsnapshotted 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_atis in the past can never be attended, so booking one returns422—"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 > 1several mentees each get their own booking on the same slot and all of them join the samemeetingUrl. One mentee cannot take two seats on the same slot — a second attempt returns422("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_attracks 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,
404on 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.
paidis the pay-first queue — charged, awaiting a mentor decision. The "N pending requests" line isrequested + paid;requestedalone 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 practice404, 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_atreturns422—"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.