Candidates API
Listing, viewing and updating candidates, imports (TSV / survey), scoring, selection, status management, and schedule confirmation. See API Conventions for shared rules.
Base path: /api/v1/projects/{projectId}/candidates
| Method |
Path |
Summary |
Role |
| GET |
/ |
List candidates |
viewer |
| GET |
/fields |
List filterable fields |
viewer |
| POST |
/import/upload |
Upload a TSV file |
member |
| GET |
/import/{fileId} |
Get uploaded file info |
viewer |
| POST |
/import/{fileId}/execute |
Execute TSV import (async) |
member |
| POST |
/import/survey/preview |
Preview a survey import |
member |
| POST |
/import/survey |
Execute a survey import (async) |
member |
| GET |
/import/{importId}/status |
Check import status |
viewer |
| GET |
/{candidateId} |
Get candidate detail |
viewer |
| PATCH |
/{candidateId} |
Update a candidate |
member |
| DELETE |
/{candidateId} |
Delete a candidate |
member |
| POST |
/score |
Recalculate scores |
member |
| PUT |
/selection |
Bulk update selection types |
member |
| PATCH |
/{candidateId}/status |
Update candidate status |
member |
| GET |
/{candidateId}/history |
Get status history |
viewer |
| GET |
/{candidateId}/email-logs |
Get email history |
viewer |
| POST |
/{candidateId}/confirm-schedule |
Confirm the schedule (admin) |
member |
All endpoints require Authorization: Bearer <access_token>. Writes (POST / PATCH / PUT / DELETE) require member or above.
candidateId is project_candidates.id
The {candidateId} path segment refers to project_candidates.id (the participation record), not candidates.id.
The ProjectCandidate Object
| Field |
Type |
Description |
id |
integer |
Project candidate ID |
projectId |
integer |
Project ID |
candidateId |
integer |
Candidate ID |
status |
enum |
One of ten statuses |
selectionType |
enum | null |
primary / reserve |
segmentId |
string | null |
Segment ID |
memo |
string | null |
Memo |
priority |
string | null |
Priority (A / B / C) |
score |
string | null |
Score (decimal rendered as a string) |
attributes |
object | null |
Attributes |
surveyResponses |
object | null |
Survey answers |
reservationId |
integer | null |
Confirmed reservation ID |
reservation |
object | null |
Reservation summary (scheduledAt / durationMinutes / interviewType / status / interviewerName) |
availableDates |
array |
Slots submitted by the candidate (id / startAt / endAt / interviewerName) |
candidate |
object |
Personal data (id / externalUserId / email / phone / name) |
createdAt / updatedAt |
string(date-time) |
Creation / update timestamps |
List Candidates
URI
GET /api/v1/projects/{projectId}/candidates
Query Parameters
| Field |
Type |
Required |
Default |
Rules |
Description |
page |
integer |
- |
1 |
Positive integer |
Page number |
limit |
integer |
- |
20 |
1–100 |
Items per page |
sort |
string |
- |
- |
created_at / score / status / name |
Sort order |
status |
enum |
- |
- |
Ten values |
Filter by status |
selectionType |
enum |
- |
- |
primary / reserve |
Filter by selection type |
segmentId |
string |
- |
- |
- |
Filter by segment |
minScore |
number |
- |
- |
- |
Minimum score |
maxScore |
number |
- |
- |
- |
Maximum score |
search |
string |
- |
- |
- |
Partial match on name or email |
responseFilter |
string |
- |
- |
JSON string |
Filter by answer content |
Using responseFilter
Filters on fields inside attributes and surveyResponses. Pass the JSON as a string.
An array of simple conditions:
[
{ "field": "Gender", "operator": "eq", "value": "Female" },
{ "field": "attributes.prefecture", "operator": "in", "value": ["Tokyo", "Kanagawa"] }
]
Or with an explicit logical operator:
{
"conditions": [
{ "field": "Gender", "operator": "eq", "value": "Female" },
{
"conditions": [
{ "field": "Age group", "operator": "eq", "value": "20s" },
{ "field": "Age group", "operator": "eq", "value": "30s" }
],
"logic": "or"
}
],
"logic": "and"
}
| Field |
Rules |
field |
Required; a field name in attributes or surveyResponses |
operator |
Required; eq / neq / contains / in |
value |
Required; string, number, or array (for in) |
logic |
Optional; and (default) / or |
Response (200 OK)
data is an array of ProjectCandidate, with pagination.
Errors
| Description |
Status code |
Status name |
Malformed query or responseFilter |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Project does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
List Filterable Fields
Returns the fields actually present in registered candidates' attributes / surveyResponses, along with sample values. Used to build segment definitions and filter UI options.
URI
GET /api/v1/projects/{projectId}/candidates/fields
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"fields": [
{ "name": "prefecture", "source": "attributes", "sampleValues": ["Tokyo", "Osaka", "Fukuoka"] },
{ "name": "Gender", "source": "surveyResponses", "sampleValues": ["Male", "Female"] }
]
},
"path": "/api/v1/projects/1/candidates/fields",
"method": "GET"
}
| Field |
Type |
Description |
name |
string |
Field name |
source |
enum |
attributes / surveyResponses |
sampleValues |
string[] |
Sample values |
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Project does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Upload TSV File
URI
POST /api/v1/projects/{projectId}/candidates/import/upload
Request Body
multipart/form-data.
| Field |
Required |
Rules |
file |
✓ |
TSV file (UTF-16LE, tab separated) |
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"file_id": "550e8400-e29b-41d4-a716-446655440000",
"file_info": { "name": "candidates.tsv", "size": 20480, "rowCount": 120 },
"columns": [
{ "index": 0, "name": "Respondent ID", "sampleValues": ["1001", "1002"] },
{ "index": 1, "name": "Name", "sampleValues": ["Taro Yamada", "Hanako Sato"] }
],
"suggested_mapping": { "external_user_id": { "column_index": 0 } },
"preview": [{ "Respondent ID": "1001", "Name": "Taro Yamada" }],
"expires_at": "2026-01-15T11:00:00Z"
},
"path": "/api/v1/projects/1/candidates/import/upload",
"method": "POST"
}
| Field |
Type |
Description |
file_id |
string(uuid) |
Temporary file ID, passed to the execute call |
file_info |
object |
Name, size, row count |
columns |
array |
Column index, name, sample values |
suggested_mapping |
object |
Mapping guessed from column names |
preview |
array |
Preview of the first rows |
expires_at |
string(date-time) |
Expiry of the temporary file |
Errors
| Description |
Status code |
Status name |
| No file attached or parse failure |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Project does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Get Uploaded File Info
Re-fetches the same payload the upload returned, so mapping work can resume after a page reload.
URI
GET /api/v1/projects/{projectId}/candidates/import/{fileId}
Path Parameters
| Field |
Rules |
fileId |
Required UUID |
Response (200 OK)
The same ImportUploadResponse as the upload.
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| File does not exist or has expired |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Execute TSV Import
Runs asynchronously and returns 201 as soon as the request is accepted. Track progress via "Check Import Status".
URI
POST /api/v1/projects/{projectId}/candidates/import/{fileId}/execute
Request Body
{
"mapping": {
"external_user_id": { "column_index": 0 },
"name": { "column_index": 1 },
"email": { "column_index": 2 },
"phone": { "column_index": 3 },
"attributes": {
"prefecture": { "type": "direct", "column_index": 4 },
"gender": { "type": "single_select", "columns": [5, 6], "mapping": "first_selected" }
},
"survey_responses": {
"mode": "all_remaining",
"exclude_columns": [2, 3]
}
}
}
Validation Rules
| Field |
Rules |
mapping.external_user_id.column_index |
Required integer ≥ 0 |
mapping.name.column_index |
Required integer ≥ 0 |
mapping.email.column_index |
Required integer ≥ 0 |
mapping.phone.column_index |
Optional integer ≥ 0 |
mapping.attributes |
Optional; field name → mapping definition |
mapping.survey_responses.mode |
Optional; all_remaining / specified |
mapping.survey_responses.exclude_columns |
Optional array of column indexes |
mapping.survey_responses.include_columns |
Optional array of column indexes |
Attribute mappings come in two shapes:
| type |
Fields |
Description |
direct |
column_index |
Use the value of that column as-is |
single_select |
columns / mapping |
Collapse several option columns into one value; mapping is a lookup table or "first_selected" |
Response (201 Created)
{
"success": true,
"status": "success",
"statusCode": 201,
"data": { "importLogId": 12, "status": "processing" },
"path": "/api/v1/projects/1/candidates/import/550e.../execute",
"method": "POST"
}
Errors
| Description |
Status code |
Status name |
| Malformed mapping |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| File or project does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Preview Survey Import
Dry-runs the mapping and filter without writing anything.
URI
POST /api/v1/projects/{projectId}/candidates/import/survey/preview
Request Body
{
"surveyId": "234567",
"mapping": {
"externalUserId": { "type": "direct", "sourceField": "Respondent ID" },
"name": { "type": "direct", "sourceField": "Please enter your name" },
"email": { "type": "direct", "sourceField": "Email address" }
},
"filter": {
"conditions": [{ "field": "Response state", "operator": "equals", "value": "Completed" }],
"logic": "and"
},
"sampleSize": 10
}
Validation Rules
| Field |
Rules |
surveyId |
Required, at least 1 character |
mapping |
Required ImportMapping (see Projects API) |
filter |
Optional ImportFilter |
sampleSize |
Optional integer 1–100 |
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"surveyId": "234567",
"totalRespondents": 1234,
"filteredRespondents": 320,
"sampleData": [
{ "externalUserId": "1001", "name": "Taro Yamada", "email": "yamada@example.com" }
],
"warnings": ["3 responses have an empty email address"]
},
"path": "/api/v1/projects/1/candidates/import/survey/preview",
"method": "POST"
}
| Field |
Type |
Description |
totalRespondents |
integer |
Respondents before filtering |
filteredRespondents |
integer |
Expected number of rows to import |
sampleData |
array |
Sample rows after the mapping is applied |
warnings |
string[] |
Warning messages |
Errors
| Description |
Status code |
Status name |
| Validation error |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Project or survey does not exist |
404 |
Not Found |
| Databricks not configured |
503 |
Service Unavailable |
Execute Survey Import
Creates the import log first, hands the work to an ECS task (production) or a subprocess (development), and returns immediately.
URI
POST /api/v1/projects/{projectId}/candidates/import/survey
Request Body
The preview body minus sampleSize.
{
"surveyId": "234567",
"mapping": { "externalUserId": { "type": "direct", "sourceField": "Respondent ID" } },
"filter": { "conditions": [], "logic": "and" }
}
Response (201 Created)
The values reflect acceptance time, so all counts are zero.
{
"success": true,
"status": "success",
"statusCode": 201,
"data": {
"importLogId": 12,
"status": "processing",
"importedCount": 0,
"skippedCount": 0,
"errorCount": 0,
"duplicateCount": 0,
"errors": []
},
"path": "/api/v1/projects/1/candidates/import/survey",
"method": "POST"
}
Errors
| Description |
Status code |
Status name |
| Validation error |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Project does not exist |
404 |
Not Found |
Flow
sequenceDiagram
participant FE as Frontend
participant API as Backend API
participant DB as PostgreSQL
participant Task as ECS task / subprocess
FE->>API: POST /candidates/import/survey
API->>DB: Create import_logs (pending)
API->>Task: Launch the import job
API-->>FE: 201 (importLogId, processing)
Task->>DB: Read answers from Databricks and register candidates
Task->>DB: Update import_logs to completed / partial / failed
loop Polling
FE->>API: GET /candidates/import/{importId}/status
API-->>FE: Progress and counts
end
Check Import Status
Works for both TSV and survey imports.
URI
GET /api/v1/projects/{projectId}/candidates/import/{importId}/status
Path Parameters
| Field |
Rules |
importId |
Required positive integer (import_logs.id) |
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"importLogId": 12,
"status": "completed",
"importedCount": 300,
"skippedCount": 15,
"errorCount": 2,
"duplicateCount": 3,
"errors": [
{ "row": 42, "respondentId": "1042", "field": "email", "message": "Invalid email address" }
]
},
"path": "/api/v1/projects/1/candidates/import/12/status",
"method": "GET"
}
status |
Meaning |
pending |
Accepted, not started |
processing |
In progress |
completed |
All rows succeeded |
partial |
Partially succeeded |
failed |
Failed |
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Import log does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Get Candidate Detail
URI
GET /api/v1/projects/{projectId}/candidates/{candidateId}
Response (200 OK)
data holds a ProjectCandidate, including reservation and availableDates.
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Candidate does not exist |
404 |
Not Found |
| Server internal error |
500 |
Internal Server Error |
Update Candidate
URI
PATCH /api/v1/projects/{projectId}/candidates/{candidateId}
Request Body
{
"attributes": { "prefecture": "Tokyo" },
"surveyResponses": { "Gender": "Female" },
"memo": "Needs follow-up",
"priority": "A"
}
Validation Rules
| Field |
Rules |
attributes |
Optional object of arbitrary shape |
surveyResponses |
Optional object of arbitrary shape |
memo |
Optional string or null |
priority |
Optional string or null (one character in the DB) |
Personal data cannot be updated
Name, email and phone belong to the candidates table and cannot be changed through this endpoint.
Response (200 OK)
data holds the updated ProjectCandidate.
Errors
| Description |
Status code |
Status name |
| Validation error |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Candidate does not exist |
404 |
Not Found |
Delete Candidate
URI
DELETE /api/v1/projects/{projectId}/candidates/{candidateId}
Response (204 No Content)
No body.
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Candidate does not exist |
404 |
Not Found |
Warning
The participation row (project_candidates) is deleted along with its email logs, scheduling tokens, preferred slots, reservation and status history. The person (candidates) remains.
Recalculate Scores
Re-applies the project's scoring_rules to every candidate.
URI
POST /api/v1/projects/{projectId}/candidates/score
Request
No body.
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": { "updated": 25 },
"path": "/api/v1/projects/1/candidates/score",
"method": "POST"
}
| Field |
Type |
Description |
updated |
integer |
Number of candidates whose score changed |
Errors
| Description |
Status code |
Status name |
Invalid scoring rules (SCORING_ERROR) |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Project does not exist |
404 |
Not Found |
Bulk Update Selection Types
Persists the results of STEP2.
URI
PUT /api/v1/projects/{projectId}/candidates/selection
Request Body
{
"selections": [
{ "candidateId": 12, "selectionType": "primary", "segmentId": "seg-1" },
{ "candidateId": 34, "selectionType": "reserve", "segmentId": "seg-1" },
{ "candidateId": 56, "selectionType": null, "segmentId": null }
]
}
Validation Rules
| Field |
Rules |
selections |
Required array |
selections[].candidateId |
Required integer (project_candidates.id) |
selections[].selectionType |
Required; primary / reserve / null (clears the selection) |
selections[].segmentId |
Optional string or null |
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": { "updated": 3 },
"path": "/api/v1/projects/1/candidates/selection",
"method": "PUT"
}
Errors
| Description |
Status code |
Status name |
| Validation error |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Project does not exist |
404 |
Not Found |
Update Candidate Status
URI
PATCH /api/v1/projects/{projectId}/candidates/{candidateId}/status
Request Body
{
"status": "contacted",
"note": "Invitation sent"
}
Validation Rules
| Field |
Rules |
status |
Required; not_contacted / contacted / waiting_response / scheduling / scheduled / interviewed / completed / declined / bounced / cancelled |
note |
Optional note |
Transition Rules
| Current |
Allowed next states |
not_contacted |
contacted / declined / cancelled |
contacted |
waiting_response / bounced / declined / cancelled |
waiting_response |
scheduling / declined / cancelled |
scheduling |
scheduled / declined / cancelled |
scheduled |
interviewed / cancelled |
interviewed |
completed / cancelled |
completed |
(none) |
declined |
(none) |
bounced |
contacted |
cancelled |
not_contacted |
Response (200 OK)
data holds the updated ProjectCandidate, and one row is appended to status_histories.
Errors
| Description |
Status code |
Status name |
Disallowed transition (INVALID_STATUS_TRANSITION) |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Candidate does not exist |
404 |
Not Found |
Get Status History
URI
GET /api/v1/projects/{projectId}/candidates/{candidateId}/history
Response (200 OK)
data is an array of StatusHistory (not paginated).
{
"success": true,
"status": "success",
"statusCode": 200,
"data": [
{
"id": 1,
"projectCandidateId": 12,
"oldStatus": "not_contacted",
"newStatus": "contacted",
"changedBy": 1,
"note": "Invitation sent",
"createdAt": "2026-01-15T09:00:00Z"
}
],
"path": "/api/v1/projects/1/candidates/12/history",
"method": "GET"
}
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Candidate does not exist |
404 |
Not Found |
Get Candidate Email History
URI
GET /api/v1/projects/{projectId}/candidates/{candidateId}/email-logs
Response (200 OK)
data is an array of EmailLog (not paginated). See the Emails API for the field list.
Errors
| Description |
Status code |
Status name |
| Token missing or invalid |
401 |
Unauthorized |
| Candidate does not exist |
404 |
Not Found |
Confirm Schedule (Admin)
Picks one of the candidate's submitted slots and creates the reservation.
URI
POST /api/v1/projects/{projectId}/candidates/{candidateId}/confirm-schedule
Request Body
Validation Rules
| Field |
Rules |
availableDateId |
Required positive integer (candidate_available_dates.id); must belong to this candidate |
Response (201 Created)
{
"success": true,
"status": "success",
"statusCode": 201,
"data": {
"message": "日程を確定しました",
"reservationId": 7,
"scheduledAt": "2026-02-03T01:00:00.000Z",
"durationMinutes": 60
},
"path": "/api/v1/projects/1/candidates/12/confirm-schedule",
"method": "POST"
}
Errors
| Description |
Status code |
Status name |
| Validation error |
400 |
Bad Request |
| Token missing or invalid |
401 |
Unauthorized |
| Insufficient role |
403 |
Forbidden |
| Candidate or preferred slot does not exist |
404 |
Not Found |
Already confirmed (SLOT_NOT_AVAILABLE) |
409 |
Conflict |
What Happens
- Fetch the row from
candidate_available_dates and verify it belongs to the candidate
- Abort with
SLOT_NOT_AVAILABLE if a confirmed reservation already exists
- Create the
reservations row
- Convert the tentative event on the interviewer's Google Calendar into a confirmed one
- Send the confirmation email to the candidate
PROTOTYPE_MODE
While PROTOTYPE_MODE=true, steps 4 and 5 are skipped and only the reservation row is created.