Emails API
Managing email templates plus sending, previewing and logging emails to candidates. See API Conventions for shared rules.
Endpoints
There are three base paths.
/api/v1/email-templates (system-wide templates)
| Method | Path | Summary | Role |
|---|---|---|---|
| GET | / |
List system templates | viewer |
/api/v1/projects/{projectId}/email-templates (project templates)
| Method | Path | Summary | Role |
|---|---|---|---|
| GET | / |
List project templates | viewer |
| POST | / |
Create a template | member |
| PATCH | /{templateId} |
Update a template | member |
| DELETE | /{templateId} |
Delete a template | member |
| POST | /preview |
Preview a template | member |
| POST | /send |
Send using a template | member |
/api/v1/projects/{projectId}/emails (sending and logs)
| Method | Path | Summary | Role |
|---|---|---|---|
| GET | /logs |
List email logs | viewer |
| POST | /send |
Bulk send emails | member |
| POST | /preview |
Preview an email | member |
All endpoints require Authorization: Bearer <access_token>; writes require member or above.
/email-templates/send and /emails/send are identical
Preview and send exist under both paths with the same input and output; either works.
The EmailTemplate Object
| Field | Type | Description |
|---|---|---|
id |
integer | Template ID |
projectId |
integer | null | System-wide when NULL |
type |
enum | invitation / reminder / confirmation / cancellation |
name |
string | Template name |
subject |
string | Subject (supports placeholders) |
body |
string | Body (supports placeholders) |
createdAt / updatedAt |
string(date-time) | Creation / update timestamps |
Placeholders
Subject and body may embed placeholders for substitution.
| Placeholder | Substituted value |
|---|---|
{{candidate_name}} |
The candidate's name |
{{project_name}} |
The project name |
The set of usable placeholders follows the implementation in services/email.ts. Use "preview" to check the rendered result.
List System Templates
Returns templates whose project_id is NULL (shared by all projects).
URI
Response (200 OK)
data is an array of EmailTemplate (not paginated).
Errors
| Description | Status code | Status name |
|---|---|---|
| Token missing or invalid | 401 | Unauthorized |
| Server internal error | 500 | Internal Server Error |
List Project Templates
URI
Response (200 OK)
data is an array of EmailTemplate.
{
"success": true,
"status": "success",
"statusCode": 200,
"data": [
{
"id": 1,
"projectId": 1,
"type": "invitation",
"name": "First invitation",
"subject": "[{{project_name}}] Interview invitation",
"body": "Dear {{candidate_name}},\n\nThank you for...",
"createdAt": "2026-01-15T09:00:00Z",
"updatedAt": "2026-01-15T09:00:00Z"
}
],
"path": "/api/v1/projects/1/email-templates",
"method": "GET"
}
Errors
| Description | Status code | Status name |
|---|---|---|
| Token missing or invalid | 401 | Unauthorized |
| Project does not exist | 404 | Not Found |
Create Template
URI
Request Body
{
"type": "invitation",
"name": "First invitation",
"subject": "[{{project_name}}] Interview invitation",
"body": "Dear {{candidate_name}},\n\nThank you for your cooperation."
}
Validation Rules
| Field | Rules |
|---|---|
type |
Required; invitation / reminder / confirmation / cancellation |
name |
Required, 1โ255 characters |
subject |
Required, 1โ500 characters |
body |
Required, at least 1 character |
Response (201 Created)
data holds the created EmailTemplate.
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 Template
URI
Path Parameters
| Field | Rules |
|---|---|
templateId |
Required positive integer |
Request Body
All creation fields become optional.
Response (200 OK)
data holds the updated EmailTemplate.
Errors
| Description | Status code | Status name |
|---|---|---|
| Validation error | 400 | Bad Request |
| Token missing or invalid | 401 | Unauthorized |
| Insufficient role | 403 | Forbidden |
| Template does not exist | 404 | Not Found |
Delete Template
URI
Response (204 No Content)
No body.
Errors
| Description | Status code | Status name |
|---|---|---|
| Token missing or invalid | 401 | Unauthorized |
| Insufficient role | 403 | Forbidden |
| Template does not exist | 404 | Not Found |
Note
Because email_logs.template_id has no FK constraint, delivery logs survive template deletion.
Preview Email
Renders the template for a single candidate. Nothing is sent.
URI
POST /api/v1/projects/{projectId}/email-templates/preview
POST /api/v1/projects/{projectId}/emails/preview
Request Body
Validation Rules
| Field | Rules |
|---|---|
templateId |
Required positive integer |
candidateId |
Required positive integer (project_candidates.id) |
Response (200 OK)
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"subject": "[Spring 2026 User Interviews] Interview invitation",
"body": "Dear Taro Yamada,\n\nThank you for your cooperation.",
"to": "yamada@example.com"
},
"path": "/api/v1/projects/1/emails/preview",
"method": "POST"
}
Errors
| Description | Status code | Status name |
|---|---|---|
| Validation error | 400 | Bad Request |
| Token missing or invalid | 401 | Unauthorized |
| Insufficient role | 403 | Forbidden |
| Template or candidate does not exist | 404 | Not Found |
Bulk Send Emails
URI
POST /api/v1/projects/{projectId}/email-templates/send
POST /api/v1/projects/{projectId}/emails/send
Request Body
Validation Rules
| Field | Rules |
|---|---|
templateId |
Required positive integer |
candidateIds |
Required array of positive integers, at least one |
Response (200 OK)
The result carries success / failure counts plus details for each failure.
{
"success": true,
"status": "success",
"statusCode": 200,
"data": {
"sent_count": 5,
"failed_count": 1,
"errors": [
{ "candidateId": 56, "email": "invalid@example", "message": "Invalid email address" }
]
},
"path": "/api/v1/projects/1/emails/send",
"method": "POST"
}
This response is snake_case
sent_count / failed_count are not camelCase.
Errors
| Description | Status code | Status name |
|---|---|---|
Validation error (e.g. empty candidateIds) |
400 | Bad Request |
| Token missing or invalid | 401 | Unauthorized |
| Insufficient role | 403 | Forbidden |
| Template or project does not exist | 404 | Not Found |
Delivery failure (EMAIL_SEND_ERROR) |
500 | Internal Server Error |
Side Effects
- Each send records the rendered subject and body in
email_logs - Sending an
invitationissues a scheduling token (scheduling_tokens) and substitutes its URL into the body - Candidate status may transition as a result
PROTOTYPE_MODE
While PROTOTYPE_MODE=true (the default), no email is actually sent. The decision goes through isEmailEnabled().
List Email Logs
URI
Query Parameters
| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
page |
integer | - | 1 |
Positive integer |
limit |
integer | - | 20 |
1โ100 |
sort |
string | - | - | - |
Response (200 OK)
data is an array of EmailLog, with pagination.
{
"success": true,
"status": "success",
"statusCode": 200,
"data": [
{
"id": 1,
"projectCandidateId": 12,
"templateId": 1,
"subject": "Interview invitation",
"body": "Dear Taro Yamada...",
"sentAt": "2026-01-15T09:00:00Z",
"status": "sent",
"sentBy": 1,
"candidateName": "Taro Yamada",
"candidateEmail": "yamada@example.com",
"senderName": "Admin"
}
],
"pagination": { "page": 1, "limit": 20, "totalCount": 42, "totalPages": 3, "hasNext": true, "hasPrevious": false },
"path": "/api/v1/projects/1/emails/logs",
"method": "GET"
}
| Field | Type | Description |
|---|---|---|
id |
integer | Log ID |
projectCandidateId |
integer | Recipient candidate |
templateId |
integer | null | Template used |
subject / body |
string | Rendered subject and body |
sentAt |
string(date-time) | Sent at |
status |
enum | sent / delivered / bounced / failed |
sentBy |
integer | User who triggered the send |
candidateName / candidateEmail |
string | null | Candidate details (joined) |
senderName |
string | null | Sender name (joined) |
Errors
| Description | Status code | Status name |
|---|---|---|
| Token missing or invalid | 401 | Unauthorized |
| Project does not exist | 404 | Not Found |
For a single candidate's history, use GET /{candidateId}/email-logs in the Candidates API.