Scheduling API (Public)
Public endpoints for candidates. They require no authentication โ access is granted by the issued scheduling token alone. See API Conventions for shared rules.
Base path: /api/v1/scheduling
| Method | Path | Summary | Auth |
|---|---|---|---|
| GET | /{token} |
Get scheduling info | Not required |
| GET | /{token}/slots |
Get bookable slots | Not required |
| POST | /{token}/submit-availability |
Submit preferred slots | Not required |
The matching screen is the frontend's /scheduling/{token} (src/app/scheduling/[token]/).
The response shape differs from every other router
This router alone does not return the standard envelope (success / statusCode / timestamp / path / method). It returns only { "data": ... }. Errors still use the shared error envelope.
Designed to expose no personal data
Anyone with the token can reach these endpoints, so responses omit internal IDs, email addresses, and interviewer details. Only the project name, candidate name and slot information are returned.
Shared Path Parameter
| Field | Rules |
|---|---|
token |
Required, at least 1 character; the value of scheduling_tokens.token |
Get Scheduling Info
Returns token validity, request details and confirmation state. Called when the screen loads.
URI
Response (200 OK)
{
"data": {
"project": {
"name": "Spring 2026 User Interviews",
"interview_duration_minutes": 60
},
"candidate": {
"name": "Taro Yamada"
},
"is_expired": false,
"is_already_scheduled": false,
"reservation": null
}
}
When a schedule is already confirmed, reservation carries the public reservation details.
{
"data": {
"project": { "name": "Spring 2026 User Interviews", "interview_duration_minutes": 60 },
"candidate": { "name": "Taro Yamada" },
"is_expired": false,
"is_already_scheduled": true,
"reservation": {
"scheduledAt": "2026-02-03T01:00:00.000Z",
"durationMinutes": 60,
"interviewType": "online_meet",
"meetingUrl": "https://meet.google.com/xxx-yyyy-zzz",
"status": "confirmed"
}
}
}
| Field | Type | Description |
|---|---|---|
project.name |
string | Project name |
project.interview_duration_minutes |
integer | Interview duration in minutes |
candidate.name |
string | Candidate's name |
is_expired |
boolean | Whether the token has expired |
is_already_scheduled |
boolean | Whether a schedule is already confirmed |
reservation |
object | null | Confirmed reservation (no internal IDs or interviewer details) |
Errors
| Description | Status code | Status name |
|---|---|---|
Token invalid or expired (SCHEDULING_TOKEN_ERROR) |
400 | Bad Request |
| Token or candidate does not exist | 404 | Not Found |
Get Bookable Slots
Returns the slots the candidate can pick, derived from interviewer availability.
URI
Response (200 OK)
{
"data": [
{
"slotId": "slot_20260203_1000",
"interviewerId": 5,
"interviewerName": "Suenari",
"startAt": "2026-02-03T01:00:00Z",
"endAt": "2026-02-03T02:00:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
slotId |
string | Slot identifier, echoed back on submission |
interviewerId |
integer | Interviewer's user ID |
interviewerName |
string | Interviewer's display name |
startAt / endAt |
string(date-time) | Slot start / end |
Errors
| Description | Status code | Status name |
|---|---|---|
| Token invalid or expired | 400 | Bad Request |
| Token does not exist | 404 | Not Found |
| Google Calendar error | 502 | Bad Gateway |
Submit Preferred Slots
Submits the slots the candidate selected.
URI
Request Body
{
"slots": [
{
"slotId": "slot_20260203_1000",
"interviewerId": 5,
"startAt": "2026-02-03T01:00:00Z",
"endAt": "2026-02-03T02:00:00Z"
},
{
"slotId": "slot_20260204_1400",
"interviewerId": 5,
"startAt": "2026-02-04T05:00:00Z",
"endAt": "2026-02-04T06:00:00Z"
}
]
}
Validation Rules
| Field | Rules |
|---|---|
slots |
Required array of 1 to 20 items |
slots[].slotId |
Required, at least 1 character |
slots[].interviewerId |
Required positive integer |
slots[].startAt |
Required ISO 8601 date-time |
slots[].endAt |
Required ISO 8601 date-time |
Response (200 OK)
Errors
| Description | Status code | Status name |
|---|---|---|
| Validation error (zero slots, more than 20, โฆ) | 400 | Bad Request |
Token invalid or expired (SCHEDULING_TOKEN_ERROR) |
400 | Bad Request |
| Candidate does not exist | 404 | Not Found |
A confirmed reservation already exists (SLOT_NOT_AVAILABLE) |
409 | Conflict |
What Happens
- Validate the token; raise
SchedulingTokenErrorif expired - Raise
SlotNotAvailableErrorif aconfirmedreservation already exists - Delete every existing preferred slot, then insert the new ones (not appended)
- If the candidate's status is
contacted/waiting_response/scheduling, move it toscheduling - Send the acknowledgement email (a failure here does not fail the request)
sequenceDiagram
participant C as Candidate
participant API as Backend API
participant DB as PostgreSQL
participant M as Gmail
C->>API: POST /scheduling/{token}/submit-availability
API->>DB: Validate scheduling_tokens
API->>DB: Check for a confirmed reservation
API->>DB: Delete all candidate_available_dates
API->>DB: Insert the new preferred slots
API->>DB: Update status to scheduling
API->>M: Send acknowledgement email
Note over API,M: Failures are logged only (the request still succeeds)
API-->>C: 200 { data: { message, selectedCount } }
PROTOTYPE_MODE
While PROTOTYPE_MODE=true, step 5 does not send anything.
What Comes Next
Submitted slots appear as availableDates on the candidate detail screen in the console. An admin picks one via POST /{candidateId}/confirm-schedule in the Candidates API to confirm the reservation.