Availability API
Endpoints for the availability engine (Phase 2). Covers a mentor declaring when they are bookable (availability rules), blocking time out (off-days, vacation mode), the public discovery of precomputed slots, and the short-lived hold a mentee places on slots before committing to a booking.
- Base URL:
/api/v1 - Auth: Sanctum bearer token —
Authorization: Bearer {token}— except the two/public/mentors/…endpoints, which are unauthenticated - Content type:
application/json
Scope note: slots are precomputed, not calculated on read. Rules describe intent; a background job (
GenerateMentorSlotsJob) expands them into concrete rows inavailability_slotsover a rolling 90-day horizon. Every mutation below dispatches a regeneration — so a slot list may lag a rule edit by the queue's latency, not by a request.
Key-casing warning: this module is inconsistent and it is load-bearing for clients. Availability rules and off-days return camelCase keys; slots and session types return snake_case keys. Both shapes are documented below verbatim.
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": "Day of week is required for weekly rules.",
"data": { "day_of_week": ["Day of week is required for weekly rules."] }
}
Status codes
| Code | Meaning |
|---|---|
| 200 | OK (read, update, activate, deactivate, hold) |
| 201 | Rule or off-day created |
| 401 | Missing/invalid token |
| 404 | No mentor profile, or resource not found / not owned by the caller |
| 409 | One or more slots are no longer holdable (lost a race) |
| 422 | Validation error |
| 500 | Server error |
Enums
Availability rule type — also the slot-invalidation priority (lower wins).
| Value | Label | Priority |
|---|---|---|
regular_weekly |
Regular Weekly | 4 |
recurring |
Recurring | 3 |
one_time |
One Time | 2 |
Vacation blocking outranks all three (priority 1).
Slot status
| Value | Label | Selectable |
|---|---|---|
available |
Available | ✅ |
held |
Being Booked | — |
booked |
Booked | — |
cancelled |
Cancelled | — |
vacation_blocked |
Vacation Blocked | — |
override_blocked |
Unavailable | — |
overwrite_blocked |
Overwrite Blocked | — |
Slot reads return every status except
cancelled. Clients must gate selection onis_selectable, not on presence in the list —heldandbookedslots are returned deliberately so a calendar can render them as taken.
available/held/bookedare derived from seat counts, not authored. They are recomputed on every seat change:bookedonce all seats are claimed,heldonce claimed plus in-checkout seats fill the slot,availableotherwise. The other four statuses are authored by the mentor (or by vacation mode) and always win.For a group slot (
max_capacity > 1) this means a slot with seats left readsavailableeven while other mentees hold or occupy seats on it. Never infer "taken" from the status alone on a group slot — useis_selectableandavailable_seats.
Day of week: 0 = Sunday … 6 = Saturday.
Shared objects
Availability rule object
{
"id": "9b6f…",
"mentorId": "8a1c…",
"sessionTypeId": null,
"ruleType": "regular_weekly",
"ruleTypeLabel": "Regular Weekly",
"dayOfWeek": 1,
"timeBlocks": [ { "start_time": "09:00", "end_time": "12:00" } ],
"rrule": null,
"specificDate": null,
"validFrom": "2026-07-01",
"validUntil": null,
"isActive": true,
"createdAt": "2026-07-01T09:00:00+00:00",
"updatedAt": "2026-07-01T09:00:00+00:00"
}
sessionTypeId: nullmeans the rule applies to every session type the mentor offers.dayOfWeekis set only forregular_weekly,rruleonly forrecurring, andspecificDateonly forone_time— the other two arenullin each case.timeBlocksis populated for all three rule types.
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
}
Seats.
max_capacityis the seat count (1 = one-on-one, >1 = group session),current_capacitythe seats claimed by settled bookings, andavailable_seatswhat is left after claimed and in-checkout seats are subtracted.is_selectableisavailable_seats > 0on an unblocked slot — gate the booking button on it, and showavailable_seatson any slot wheremax_capacity > 1.
held_untilis the latest hold expiry on the slot and is a display hint only. On a group slot several mentees can be in checkout at once, so it does not tell you how many seats are reserved —available_seatsdoes.
All timestamps are UTC.
buffer_ends_atisends_atplus the session type'sbuffer_after_minutes— the mentor is occupied until then, so overlap checks use the buffer, notends_at. This is the same slot object the Booking and Messaging APIs embed.
Off-day object
{
"id": "3c7d…",
"mentorId": "8a1c…",
"offFrom": "2026-08-01",
"offUntil": "2026-08-05",
"reason": "Conference",
"isFullDay": true,
"offTimeBlocks": null,
"isVacation": false,
"createdAt": "2026-07-01T09:00:00+00:00",
"updatedAt": "2026-07-01T09:00:00+00:00"
}
Session-type object
{
"id": "6c3f…",
"mentor_id": "8a1c…",
"title": "Career Coaching",
"description": "…",
"session_model": "regular_weekly",
"session_model_label": "Regular Weekly",
"duration_minutes": 60,
"buffer_after_minutes": 15,
"is_free": false,
"price": "90.00",
"currency": "USD",
"max_capacity": 1,
"follow_up_count": 2,
"follow_up_interval_days": 7,
"follow_up_flexibility_days": 2,
"is_active": true,
"created_at": "2026-07-01T09:00:00+00:00",
"updated_at": "2026-07-01T09:00:00+00:00"
}
Mentor endpoints — availability rules
All require auth:sanctum and a mentor profile. A caller without one gets 404
"Mentor profile not found."
Rules are owner-scoped: a rule id that does not exist, and one belonging to another
mentor, are indistinguishable — both return 404 "Availability rule not found." on every
handler (show, update, activate, deactivate), so ids don't leak. A genuine server fault is
still a 500.
1. List availability rules
GET /api/v1/mentor/availability-rules
Returns the authenticated mentor's rules, newest first.
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
is_active |
boolean | no | Filter by active state — see caveat |
session_type |
uuid | no | Only rules bound to this session type |
Caveat: the
is_activefilter is applied only when the value is truthy. Passingis_active=0oris_active=falsedoes not return inactive rules — it returns all of them. To list deactivated rules, fetch unfiltered and filter client-side.
200 Response
{
"status": "success",
"message": "Availability rules retrieved successfully.",
"data": { "availability_rules": [ { "…availability rule object…" } ] }
}
Errors: 401 unauthenticated · 404 no mentor profile · 500 server error
2. Create an availability rule
POST /api/v1/mentor/availability-rules
Creates one rule and dispatches slot regeneration.
Body
| Field | Type | Required | Rules |
|---|---|---|---|
session_type_id |
uuid | no | Must exist in session_types. Omit or null → applies to all session types |
rule_type |
string | yes | regular_weekly · recurring · one_time |
day_of_week |
integer | conditional | Required when rule_type=regular_weekly. 0–6 |
time_blocks |
array | yes | At least 1 block, for every rule type |
time_blocks.*.start_time |
string | yes | H:i (24-hour, e.g. 09:00) |
time_blocks.*.end_time |
string | yes | H:i, must be after its own start_time |
rrule |
string | conditional | Required when rule_type=recurring. iCal RRULE, max 500 chars |
specific_date |
date | conditional | Required when rule_type=one_time. Today or later |
valid_from |
date | no | Defaults to today when omitted |
valid_until |
date | no | Must be on/after valid_from. null = open-ended |
Times in
time_blocksare interpreted in the mentor's own timezone (mentor_profiles.timezone, falling back toUTC) and stored as UTC on the generated slots.
Example request
{
"rule_type": "regular_weekly",
"day_of_week": 1,
"time_blocks": [
{ "start_time": "09:00", "end_time": "12:00" },
{ "start_time": "14:00", "end_time": "17:00" }
],
"valid_from": "2026-08-01"
}
Recurring example
{
"rule_type": "recurring",
"rrule": "FREQ=WEEKLY;BYDAY=TU,TH;INTERVAL=2",
"time_blocks": [ { "start_time": "10:00", "end_time": "13:00" } ]
}
201 Response
{
"status": "success",
"message": "Availability rule created successfully.",
"data": { "availability_rule": { "…availability rule object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile · 422 validation · 500 server error
3. Create availability rules in bulk
POST /api/v1/mentor/availability-rules/bulk
Creates many rules in one request — the natural shape for a "set my weekly schedule" screen
that submits seven days at once. Each element is validated with the same rules as
endpoint 2, with the conditional requirements resolved per element from that element's own
rule_type.
Body
{
"availability_rules": [
{
"rule_type": "regular_weekly",
"day_of_week": 1,
"time_blocks": [ { "start_time": "09:00", "end_time": "17:00" } ]
},
{
"rule_type": "regular_weekly",
"day_of_week": 3,
"time_blocks": [ { "start_time": "09:00", "end_time": "12:00" } ]
}
]
}
A bare top-level JSON array is also accepted and normalized into
availability_rulesbefore validation, so[ {…}, {…} ]works identically.
Not transactional. Rules are created in a loop. If element 5 fails at the database level, elements 1–4 are already persisted and the response is
500. Validation failures are caught before any write (422, nothing persisted) — only a mid-loop write failure leaves a partial result. Re-listing after a500is the safe recovery.
201 Response
{
"status": "success",
"message": "Availability rules created successfully.",
"data": { "availability_rules": [ { "…availability rule object…" } ] }
}
Errors: 401 unauthenticated · 404 no mentor profile · 422 validation · 500 partial write
4. Get an availability rule
GET /api/v1/mentor/availability-rules/{id}
Path parameters
| Param | Type | Notes |
|---|---|---|
id |
uuid | Must belong to the authenticated mentor |
200 Response
{
"status": "success",
"message": "Availability rule retrieved successfully.",
"data": { "availability_rule": { "…availability rule object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile / not found / not the caller's rule
5. Update an availability rule
PUT|PATCH /api/v1/mentor/availability-rules/{id}
Every field is optional (sometimes) — send only what changes. Dispatches slot regeneration.
Body
| Field | Type | Rules |
|---|---|---|
session_type_id |
uuid | Nullable, must exist |
rule_type |
string | regular_weekly · recurring · one_time |
day_of_week |
integer | Nullable, 0–6 |
time_blocks |
array | Min 1 block; start_time / end_time required with the block |
rrule |
string | Nullable, max 500 |
specific_date |
date | Nullable, today or later |
valid_from |
date | — |
valid_until |
date | Nullable, on/after valid_from |
is_active |
boolean | — |
Unlike create, update enforces no conditional requirements — changing
rule_typetorecurringwithout supplying anrrulepasses validation and yields a rule that generates no slots. Send the discriminating field alongside anyrule_typechange.
200 Response
{
"status": "success",
"message": "Availability rule updated successfully.",
"data": { "availability_rule": { "…availability rule object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile / not found / not the caller's rule ·
422 validation · 500 server error
6. Activate an availability rule
PATCH /api/v1/mentor/availability-rules/{id}/activate
Sets is_active = true and dispatches regeneration. No request body.
200 Response
{
"status": "success",
"message": "Availability rule activated successfully.",
"data": { "availability_rule": { "…availability rule object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile / not found / not the caller's rule ·
500 server error
7. Deactivate an availability rule
DELETE /api/v1/mentor/availability-rules/{id}
Soft deactivation, not deletion — sets is_active = false and keeps the row, so it can
be revived through endpoint 6. Regeneration then cancels the future available slots the
rule produced; booked and held slots are preserved.
200 Response
{
"status": "success",
"message": "Availability rule deactivated successfully.",
"data": { }
}
Errors: 401 unauthenticated · 404 no mentor profile / not found / not the caller's rule ·
500 server error
Mentor endpoints — off-days & vacation
Time blocked out of an otherwise valid rule. Both dispatch regeneration; only available
slots are mutated, so an off-day never silently destroys a booking.
8. List off-days
GET /api/v1/mentor/off-days
Returns the mentor's off-days, most recent off_from first.
200 Response
{
"status": "success",
"message": "Off days retrieved successfully.",
"data": { "off_days": [ { "…off-day object…" } ] }
}
Errors: 401 unauthenticated · 404 no mentor profile
9. Create an off-day
POST /api/v1/mentor/off-days
Body
| Field | Type | Required | Rules |
|---|---|---|---|
off_from |
date | yes | Today or later |
off_until |
date | yes | On/after off_from |
reason |
string | no | Max 100 chars |
is_full_day |
boolean | no | Defaults true |
is_vacation |
boolean | no | Marks the range as vacation |
off_time_blocks |
array | conditional | Required when is_full_day=false. Min 1 block |
off_time_blocks.*.start_time |
string | with block | H:i |
off_time_blocks.*.end_time |
string | with block | H:i, after its own start_time |
Partial-hour off-days must be a single date. When
is_full_day=false,off_fromandoff_untilmust be equal — otherwise validation fails onoff_until. To block the same hours across several days, create one off-day per day.
201 Response
{
"status": "success",
"message": "Off day created successfully.",
"data": { "off_day": { "…off-day object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile · 422 validation
10. Get, update, delete an off-day
GET /api/v1/mentor/off-days/{id}
PUT /api/v1/mentor/off-days/{id}
PATCH /api/v1/mentor/off-days/{id}
DELETE /api/v1/mentor/off-days/{id}
Update takes the same fields as endpoint 9, all optional; the single-date constraint is
re-checked only when is_full_day is present and false. Delete is a hard delete —
unlike availability rules, the row is removed. All three dispatch regeneration; deleting an
off-day restores the slots it had suppressed.
200 Response — { "off_day": { … } } for get/update; { } for delete.
Errors: 401 unauthenticated · 404 no mentor profile / not found · 422 validation
11. Enable vacation mode
POST /api/v1/mentor/vacation-mode
Body
| Field | Type | Required | Rules |
|---|---|---|---|
vacation_ends_at |
date | no | Must be after today. Omit or null for indefinite |
Two different behaviours by design. An indefinite vacation (
vacation_ends_atnull) blocks every futureavailableslot immediately, in-request. A dated vacation injects a synthetic off-day range and blocks on the next generation run, so slots stayavailableuntil the queued job lands.
200 Response
{
"status": "success",
"message": "Vacation mode enabled successfully.",
"data": { "mentor": { "…mentor profile object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile · 422 end date not in the future
12. Disable vacation mode
DELETE /api/v1/mentor/vacation-mode
Clears vacation_mode and vacation_ends_at, and restores future vacation_blocked slots
back to available.
200 Response
{
"status": "success",
"message": "Vacation mode disabled successfully.",
"data": { "mentor": { "…mentor profile object…" } }
}
Errors: 401 unauthenticated · 404 no mentor profile
Slot endpoints
13. Public — list slots for a mentor's session type
GET /api/v1/public/mentors/{mentorId}/session-types/{sessionTypeId}/slots
Unauthenticated. The primary discovery endpoint — what a public mentor profile renders
its calendar from. Ordered by starts_at ascending.
Path parameters
| Param | Type | Notes |
|---|---|---|
mentorId |
uuid | Mentor profile id |
sessionTypeId |
uuid | Session type id |
Query parameters
| Param | Type | Required | Notes |
|---|---|---|---|
from |
datetime | no | Window start. Defaults to now |
until |
datetime | no | Window end. Defaults to now + 90 days |
from/untilare not validated — they are parsed directly. A malformed value returns 500, not 422. Send ISO-8601.
An unknown
mentorIdorsessionTypeIdreturns 200 with an emptyslotsarray, not a 404 — the endpoint is a filtered query, not a resource lookup.
200 Response
{
"status": "success",
"message": "Availability slots retrieved successfully.",
"data": { "slots": [ { "…slot object…" } ] }
}
Errors: 500 unparseable from / until
14. Public — list a mentor's session types
GET /api/v1/public/mentors/{mentorId}/session-types
Unauthenticated. The companion to endpoint 13 — pick a session type, then load its slots.
Returns inactive session types too. The
is_activefilter is currently disabled in the service, so clients that must not offer retired session types should filter onis_activethemselves.
200 Response
{
"status": "success",
"message": "Available session types retrieved successfully.",
"data": { "session_types": [ { "…session-type object…" } ] }
}
15. Mentor — list my slots for a session type
GET /api/v1/mentor/slots-by-session-type/{sessionTypeId}
Authenticated equivalent of endpoint 13, scoped to the caller's own mentor profile — the
mentor's view of their own generated calendar. Same from / until query parameters and
the same defaults.
200 Response
{
"status": "success",
"message": "Available slots retrieved successfully.",
"data": { "slots": [ { "…slot object…" } ] }
}
Errors: 401 unauthenticated · 404 no mentor profile · 500 unparseable dates
16. Hold slots
POST /api/v1/mentor/slots/hold
Reserves slots for 10 minutes while the caller completes a booking, preventing two
mentees from claiming the same time. Runs inside a transaction with SELECT … FOR UPDATE,
so concurrent callers cannot both win.
Despite the
mentorprefix, this is a mentee-facing endpoint. It authorizes on the authenticated user, not on a mentor profile — any authenticated user may hold slots. The path is a routing artifact, not an access rule.
Body
| Field | Type | Required | Rules |
|---|---|---|---|
slot_ids |
array | yes | Min 1 |
slot_ids.* |
uuid | yes | Must exist in availability_slots |
A hold reserves one seat, not the whole slot. A slot is holdable when it is not blocked
(cancelled / vacation_blocked / override_blocked / overwrite_blocked) and it still
has a free seat — that is, claimed seats plus seats already in checkout are fewer than
max_capacity. Equivalently: available_seats > 0.
On a one-on-one slot (max_capacity: 1) the first hold consumes the slot and it flips to
status: held, exactly as before. On a group slot several mentees hold seats
concurrently and the slot stays available until the seats run out.
All slots in one request share a single booking_attempt_id — pass that id to the booking
endpoint to convert the hold into a booking.
Example request
{ "slot_ids": ["1a2b…", "3c4d…"] }
200 Response — note this endpoint returns a raw payload, not a resource:
{
"status": "success",
"message": "Slots held successfully.",
"data": {
"booking_attempt_id": "7f8e…",
"held_slots": ["1a2b…", "3c4d…"],
"expires_at": "2026-07-20T09:10:00+00:00"
}
}
409 Response — the whole request fails atomically; no partial holds are taken:
{
"status": "error",
"message": "Slots no longer available: 1a2b…, 3c4d…",
"data": { }
}
The 409 message is one of "One or more slots not found." (an id resolved to nothing) or
"Slots no longer available: <ids>" (someone else got there first). Both mean re-fetch the
slot list and let the user pick again.
Errors: 401 unauthenticated · 409 slots unavailable · 422 validation · 500 server error
Expiry
Holds are released by the slots:release-expired-holds command, which runs every minute.
It stamps released_at on every slot_holds row whose expires_at has passed, then
recomputes the status of the affected slots.
slot_holds rows are the source of truth for reserved seats, not audit records: a hold
is live while released_at IS NULL AND expires_at > NOW(). Rows are never deleted, but a
released or expired one reserves nothing. Because seats are counted per row, expiring one
mentee's hold on a group slot frees exactly their seat and leaves every co-tenant's hold
alone.
Clients should treat expires_at as a soft deadline and re-hold if the user stalls, rather
than assuming a hold survives to the moment of booking.
Related endpoints
Slots surface in two other modules, documented alongside their own flows:
| Endpoint | Doc |
|---|---|
GET /api/v1/mentee/bookings/follow-up-slots |
booking-api.md |
GET /api/v1/chat/shareable-slots |
messaging-api.md |
POST /api/v1/chat/threads/{thread}/share-availability |
messaging-api.md |
Generation semantics
Useful context for anyone reasoning about why a slot did or did not appear.
- Horizon: 90 days from now. Candidates outside
(now, horizon]are skipped. - Timezone: rule times are read in the mentor's timezone and stored UTC.
- Identity: a slot is keyed
(mentor_id, session_type_id, starts_at)— a unique index, so regeneration is idempotent. - Preservation:
bookedandheldslots inside the horizon are loaded first and never mutated; theirstarts_at … buffer_ends_atranges are treated as occupied. - Scope: only active session types and rules valid within the horizon are expanded.
A rule with
session_type_id: nullexpands against every session type. - Recurrence:
recurringrules expand via thesimshaun/recurrlibrary. - Indefinite vacation short-circuits everything — all available slots become
vacation_blockedand no expansion runs. - Cron:
slots:generateruns daily; every rule / off-day / vacation / session-type mutation additionally dispatchesGenerateMentorSlotsJob(3 tries, 300s timeout).