On-call schedule management service for Induty.
Handles schedule creation, shift management, and current on-call lookup.
- Configuration
- Access Model
- Integration with teams service
- API
- GET /healthz /readyz
- POST /api/v1/schedules
- GET /api/v1/schedules
- GET /api/v1/schedules/:id
- PUT /api/v1/schedules/:id
- DELETE /api/v1/schedules/:id
- GET /api/v1/schedules/:id/shifts
- POST /api/v1/schedules/:id/shifts
- PUT /api/v1/schedules/:id/shifts/:shift_id
- DELETE /api/v1/schedules/:id/shifts/:shift_id
- GET /api/v1/schedules/:id/oncall
- GET /api/v1/schedules/oncall
The service reads its config from config.yaml. Any field can be overridden by an environment variable.
port: "8004"
database_dsn: "host=localhost user=induty password=secret dbname=induty_schedules port=5432 sslmode=disable"
jwt_secret: "change-me-in-production"
read_timeout: "10s"
write_timeout: "10s"
teams_service_url: "https://teams:8443"
tls_client_cert_file: "./certs/schedules/client.crt"
tls_client_key_file: "./certs/schedules/client.key"
tls_ca_cert_file: "./certs/ca/ca.crt"| Variable | Description | Example |
|---|---|---|
INDUTY_SCHEDULES_PORT |
Service port | 8004 |
INDUTY_SCHEDULES_DATABASE_DSN |
PostgreSQL DSN string | host=postgres user=... |
INDUTY_SCHEDULES_JWT_SECRET |
JWT signing secret | supersecret |
INDUTY_SCHEDULES_TEAMS_SERVICE_URL |
Base URL of the teams service | https://teams:8443 |
INDUTY_SCHEDULES_TLS_CLIENT_CERT_FILE |
Path to the mTLS client certificate | /certs/schedules/client.crt |
INDUTY_SCHEDULES_TLS_CLIENT_KEY_FILE |
Path to the mTLS client key | /certs/schedules/client.key |
INDUTY_SCHEDULES_TLS_CA_CERT_FILE |
Path to the CA certificate for verifying teams service | /certs/ca/ca.crt |
Environment variables take priority over values in config.yaml.
GET /healthzandGET /readyzrequire no authentication.- All
/api/v1/...routes require a JWT in theAuthorization: Bearer <access_token>header. - The global
adminhas full access to all schedules and shifts. - For non-
adminusers, access is restricted to teams they belong to. - Team membership is verified through the
teams service.
⚠️ Key difference from incidents: most write operations require not just team membership, but the owner role in that team.
| Operation | Who can perform it |
|---|---|
| Create schedule | admin or team owner |
| List schedules | admin (all) or member (own teams only) |
| View schedule | admin or team member |
| Update schedule | admin or team owner |
| Delete schedule | admin or team owner |
| View shifts | admin or team member |
| Add shift | admin or team owner |
| Update shift | admin or team owner |
| Delete shift | admin or team owner |
| View on-call by schedule | admin or team member |
| View on-call by team | admin or team member |
- If a non-
adminhas no teams,GET /api/v1/schedulesreturns an empty array. - If the specified
team_idis one the user doesn't belong to, the service returns403. - Schedule names must be unique; on conflict the service returns
409 Conflict. - The
team_idof a schedule cannot be changed after creation.
schedules uses the teams service to verify team access and user roles.
Communication with the teams service is secured with mTLS:
- the
schedulesclient loads a client certificate and key; - the
teams servicecertificate is validated against the configured CA; - the TLS config uses
ServerName: "teams"; - minimum TLS version is TLS 1.3;
- HTTP client timeout is 3 seconds.
The teams service certificate must include teams in its CN or SAN.
| Method | Path | Purpose |
|---|---|---|
GET |
/internal/users/:user_id/teams |
Get the list of teams a user belongs to |
GET |
/internal/teams/:team_id/members/:user_id |
Check if a user is a member of a team |
GET |
/internal/teams/:team_id/members/:user_id/owner |
Check if a user is a team owner |
If the teams service is unavailable, operations will return 503 Service Unavailable.
Base URL: http://localhost:8004
All responses are returned as JSON:
// Success
{ "success": true, "data": { ... } }
// Success for delete operations
{ "success": true, "message": "..." }
// Error
{ "error": "error description" }Service health check.
Auth: not required
Response 200:
{ "status": "ok" }/healthz— checks that the HTTP server is up./readyz— additionally pings the database.
Create a new schedule.
Auth: Authorization: Bearer <access_token>
Access: global admin or owner of team_id
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | Name, 2–255 characters; must be unique |
description |
string | ❌ | Schedule description |
team_id |
uint | ✅ | Team ID; cannot be changed after creation |
Example request:
curl -X POST http://localhost:8004/api/v1/schedules \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-d '{
"name": "Backend On-Call",
"description": "Primary on-call rotation for the backend team",
"team_id": 10
}'Response 201:
{
"success": true,
"data": {
"id": 1,
"name": "Backend On-Call",
"description": "Primary on-call rotation for the backend team",
"team_id": 10,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:00Z"
}
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid request body |
401 |
Token missing or invalid |
403 |
No owner role in the specified team |
409 |
A schedule with this name already exists |
503 |
Failed to verify role via teams service |
Returns a list of schedules.
Auth: Authorization: Bearer <access_token>
Access:
adminsees all schedules;- non-
adminsees only schedules for their teams.
Query parameters:
| Parameter | Type | Description |
|---|---|---|
team_id |
uint | Filter by team |
Example request:
curl "http://localhost:8004/api/v1/schedules?team_id=10" \
-H "Authorization: Bearer <access_token>"Response 200:
{
"success": true,
"data": [
{
"id": 1,
"name": "Backend On-Call",
"description": "Primary on-call rotation for the backend team",
"team_id": 10,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:00Z"
}
]
}Notes:
- If the user has no accessible teams, an empty array is returned.
- If the specified
team_idis one the user doesn't belong to, the service returns403.
Errors:
| Code | Reason |
|---|---|
401 |
Token missing or invalid |
403 |
No access to the specified team |
503 |
Failed to fetch team membership from teams service |
500 |
Database error |
Returns a schedule by ID.
Auth: Authorization: Bearer <access_token>
Access: admin or member of the schedule's team
Example request:
curl http://localhost:8004/api/v1/schedules/1 \
-H "Authorization: Bearer <access_token>"Response 200:
{
"success": true,
"data": {
"id": 1,
"name": "Backend On-Call",
"description": "Primary on-call rotation for the backend team",
"team_id": 10,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:00Z"
}
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid ID |
401 |
Token missing or invalid |
403 |
No access to the schedule |
404 |
Schedule not found |
Update a schedule's name and description. The team (team_id) cannot be changed.
Auth: Authorization: Bearer <access_token>
Access: admin or owner of the schedule's team
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | ✅ | New name, 2–255 characters |
description |
string | ❌ | New description |
Example request:
curl -X PUT http://localhost:8004/api/v1/schedules/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-d '{
"name": "Backend On-Call v2",
"description": "Updated rotation schedule"
}'Response 200:
{
"success": true,
"data": {
"id": 1,
"name": "Backend On-Call v2",
"description": "Updated rotation schedule",
"team_id": 10,
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T11:00:00Z"
}
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid request body |
401 |
Token missing or invalid |
403 |
No owner role |
404 |
Schedule not found |
500 |
Failed to update schedule |
Delete a schedule.
Auth: Authorization: Bearer <access_token>
Access: admin or owner of the schedule's team
Example request:
curl -X DELETE http://localhost:8004/api/v1/schedules/1 \
-H "Authorization: Bearer <access_token>"Response 200:
{
"success": true,
"message": "schedule deleted"
}Errors:
| Code | Reason |
|---|---|
401 |
Token missing or invalid |
403 |
No owner role |
404 |
Schedule not found |
500 |
Failed to delete schedule |
Returns the list of shifts for a schedule.
Auth: Authorization: Bearer <access_token>
Access: admin or member of the schedule's team
Example request:
curl http://localhost:8004/api/v1/schedules/1/shifts \
-H "Authorization: Bearer <access_token>"Response 200:
{
"success": true,
"data": [
{
"id": 1,
"schedule_id": 1,
"user_id": 42,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z",
"created_at": "2026-04-01T10:00:00Z",
"updated_at": "2026-04-01T10:00:00Z"
}
]
}Errors:
| Code | Reason |
|---|---|
401 |
Token missing or invalid |
403 |
No access to the schedule |
404 |
Schedule not found |
500 |
Failed to read shifts |
Add a shift to a schedule.
Auth: Authorization: Bearer <access_token>
Access: admin or owner of the schedule's team
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
uint | ✅ | On-call user ID |
starts_at |
string (RFC3339) | ✅ | Shift start time |
ends_at |
string (RFC3339) | ✅ | Shift end time; must be after starts_at |
Example request:
curl -X POST http://localhost:8004/api/v1/schedules/1/shifts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-d '{
"user_id": 42,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z"
}'Response 201:
{
"success": true,
"data": {
"id": 1,
"schedule_id": 1,
"user_id": 42,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z",
"created_at": "2026-04-01T10:05:00Z",
"updated_at": "2026-04-01T10:05:00Z"
}
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid request body or ends_at is not after starts_at |
401 |
Token missing or invalid |
403 |
No owner role |
404 |
Schedule not found |
500 |
Failed to add shift |
Update a shift.
Auth: Authorization: Bearer <access_token>
Access: admin or owner of the schedule's team
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
user_id |
uint | ✅ | New on-call user |
starts_at |
string (RFC3339) | ✅ | New shift start time |
ends_at |
string (RFC3339) | ✅ | New shift end time; must be after starts_at |
Example request:
curl -X PUT http://localhost:8004/api/v1/schedules/1/shifts/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <access_token>" \
-d '{
"user_id": 55,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z"
}'Response 200:
{
"success": true,
"data": {
"id": 1,
"schedule_id": 1,
"user_id": 55,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z",
"created_at": "2026-04-01T10:05:00Z",
"updated_at": "2026-04-01T12:00:00Z"
}
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid request body or ends_at is not after starts_at |
401 |
Token missing or invalid |
403 |
No owner role |
404 |
Schedule or shift not found |
500 |
Failed to update shift |
Delete a shift.
Auth: Authorization: Bearer <access_token>
Access: admin or owner of the schedule's team
Example request:
curl -X DELETE http://localhost:8004/api/v1/schedules/1/shifts/1 \
-H "Authorization: Bearer <access_token>"Response 200:
{
"success": true,
"message": "shift deleted"
}Errors:
| Code | Reason |
|---|---|
401 |
Token missing or invalid |
403 |
No owner role |
404 |
Schedule or shift not found |
500 |
Failed to delete shift |
Returns the current on-call person for a specific schedule (at the time of the request).
Auth: Authorization: Bearer <access_token>
Access: admin or member of the schedule's team
Example request:
curl http://localhost:8004/api/v1/schedules/1/oncall \
-H "Authorization: Bearer <access_token>"Response 200 (someone is on-call):
{
"success": true,
"data": {
"schedule_id": 1,
"schedule_name": "Backend On-Call",
"team_id": 10,
"user_id": 42,
"shift_id": 1,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z"
}
}Response 200 (no one on-call):
{
"success": true,
"data": null,
"message": "no one is on-call right now"
}Errors:
| Code | Reason |
|---|---|
400 |
Invalid schedule ID |
401 |
Token missing or invalid |
403 |
No access to the schedule |
404 |
Schedule not found |
Returns all current on-call users across all schedules for a team.
Auth: Authorization: Bearer <access_token>
Access: admin or team member
Query parameters:
| Parameter | Type | Required | Description |
|---|---|---|---|
team_id |
uint | ✅ | Team ID |
Example request:
curl "http://localhost:8004/api/v1/schedules/oncall?team_id=10" \
-H "Authorization: Bearer <access_token>"Response 200 (someone is on-call):
{
"success": true,
"data": [
{
"schedule_id": 1,
"schedule_name": "Backend On-Call",
"team_id": 10,
"user_id": 42,
"shift_id": 1,
"starts_at": "2026-04-07T09:00:00Z",
"ends_at": "2026-04-08T09:00:00Z"
},
{
"schedule_id": 2,
"schedule_name": "Infra On-Call",
"team_id": 10,
"user_id": 7,
"shift_id": 5,
"starts_at": "2026-04-07T00:00:00Z",
"ends_at": "2026-04-14T00:00:00Z"
}
]
}Response 200 (no one on-call):
{
"success": true,
"data": [],
"message": "no one is on-call right now"
}