Scheduling API
The Scheduling API reads the schedule calendar Runnit builds from task assignments: who is booked on what, when, and for how long. It also manages organisation closure dates (public holidays and office closures) and per-user blocked-out dates (leave and appointments), both of which the scheduler skips when it books work.
Unscheduled project tasks do not appear here. They remain available through project and task APIs until a planned start creates schedule entries. This keeps backlog capture separate from resource capacity.
Deleted tasks do not appear, even if legacy schedule data still refers to them.
Authentication: every endpoint requires an API key. See API Keys & Authentication.
GET /api/v1/schedule
Section titled “GET /api/v1/schedule”Get the schedule entries for one or more users. Each entry is one booked segment of a task on a user’s calendar, enriched with task, project, and assignee details. Entries for projects you cannot see are filtered out.
Any organisation member with schedule access can call this. Every requested user must share an organisation with you.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| userIds | string | Yes | Comma-separated list of user IDs to fetch schedules for. |
The endpoint returns each user’s full schedule; there is no date-range filter. Filter client-side if you only need a window.
Example request
curl 'https://api.runnit.io/api/v1/schedule?userIds=<user-uuid-1>,<user-uuid-2>' \ -H 'Authorization: Bearer rnk_your_key'Response
A JSON array of schedule entries:
[ { "id": "8fd2b3c4-...", "taskId": "4be2d871-...", "taskNumber": "TASK-014", "projectId": "1f0e9a52-...", "projectName": "Winter Launch", "clientName": "Northwind", "name": "Draft homepage copy", "description": "First pass for internal review.", "status": "scheduled", "priority": "high", "assignedToId": "d3a6f0c1-...", "assignedTo": { "id": "d3a6f0c1-...", "firstName": "Alex", "lastName": "Chen", "displayName": "Alex Chen", "avatarUrl": "https://...", "currentTitle": "Creative Director" }, "plannedStartDate": "2026-07-13T09:00:00.000Z", "plannedEndDate": "2026-07-13T15:30:00.000Z", "durationMinutes": 360, "allocatedHours": 6, "estimatedHours": 6, "segmentIndex": 0, "totalSegments": 1, "isOverdue": false, "daysOverdue": 0, "tags": ["copy", "homepage"], "metadata": {} }]A task that spans several days appears as several entries; segmentIndex and
totalSegments tell you which piece you are looking at. plannedStartDate
and plannedEndDate are the segment’s start and end times, not the task’s
overall dates. status is one of pending, scheduled, in_progress,
completed, cancelled; priority is one of low, medium, high,
critical. clientName, description, taskNumber, assignedTo,
allocatedHours, estimatedHours, and tags can be null or absent.
An empty array means the requested users have no schedule entries. It does not mean their accessible projects have no backlog tasks.
Errors
400ifuserIdsis missing, or you have no active organisation.403if any requested user does not share an organisation with you, or none of the entries belong to projects you can see.
GET /api/v1/schedule/milestones
Section titled “GET /api/v1/schedule/milestones”Get the milestones of every project that appears in the selected users’ schedules. Useful for drawing key dates alongside the schedule calendar.
Any organisation member with schedule access can call this. Every requested user must share an organisation with you.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| userIds | string | Yes | Comma-separated list of user IDs. |
Example request
curl 'https://api.runnit.io/api/v1/schedule/milestones?userIds=<user-uuid-1>,<user-uuid-2>' \ -H 'Authorization: Bearer rnk_your_key'Response
A JSON array of milestones:
[ { "id": "0a4f6c33-...", "projectId": "1f0e9a52-...", "projectName": "Winter Launch", "name": "Client sign-off", "type": "custom", "targetDate": "2026-08-15", "isFirm": true, "description": "Final approval from the client team.", "sourceKey": null, "orderIndex": 0, "metadata": {} }]targetDate is a plain YYYY-MM-DD date. description and sourceKey can
be null. An empty array means the selected users have no scheduled work, or
their projects have no milestones.
Errors
400ifuserIdsis missing, or you have no active organisation.403if any requested user does not share an organisation with you.
GET /api/v1/schedule/tasks/:taskId
Section titled “GET /api/v1/schedule/tasks/:taskId”Get a task with its project, assigned user, and subtasks in one call. This is the detail view behind a schedule entry.
Any organisation member with schedule access can call this, for tasks in projects their organisation can see.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| taskId | UUID | Yes | Task ID. |
Example request
curl https://api.runnit.io/api/v1/schedule/tasks/<task-id> \ -H 'Authorization: Bearer rnk_your_key'Response
The task object with related records attached:
{ "id": "4be2d871-...", "projectId": "1f0e9a52-...", "name": "Draft homepage copy", "status": "scheduled", "priority": "high", "progress": 0, "assignedToId": "d3a6f0c1-...", "estimatedHours": 6, "plannedStartDate": "2026-07-13T00:00:00.000Z", "plannedEndDate": "2026-07-14T00:00:00.000Z", "project": { "id": "1f0e9a52-...", "name": "Winter Launch", "status": "active" }, "assignedUser": { "id": "d3a6f0c1-...", "displayName": "Alex Chen", "email": "alex@example.com" }, "subtasks": []}assignedUser is null for unassigned tasks. The task and project include
their other standard fields.
Errors
404if the task or its project does not exist.403if the project belongs to an organisation you cannot access.
Closure dates
Section titled “Closure dates”Closure dates are days the organisation does not work: public holidays and office closures. The scheduler skips them when booking task time. All closure-date endpoints operate on your own organisation.
A closure date looks like this:
{ "id": "e19c50f7-...", "organizationId": "8c2d41b7-...", "date": "2026-12-25", "name": "Christmas Day", "description": "Public holiday", "type": "holiday", "isRecurring": true, "createdAt": "2026-01-05T00:00:00.000Z", "updatedAt": "2026-01-05T00:00:00.000Z", "createdById": "d3a6f0c1-..."}type is one of holiday, closure, other. description and
createdById can be null.
Reading closure dates is open to any organisation member. Creating, updating, and deleting them requires permission to manage organisation settings, which organisation admins and owners have.
GET /api/v1/closure-dates
Section titled “GET /api/v1/closure-dates”List your organisation’s closure dates, optionally filtered to a date range.
Any organisation member can call this.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| startDate | ISO 8601 date | No | Start of the range. Only applied when endDate is also supplied. |
| endDate | ISO 8601 date | No | End of the range. Only applied when startDate is also supplied. |
Example request
curl 'https://api.runnit.io/api/v1/closure-dates?startDate=2026-12-01&endDate=2027-01-31' \ -H 'Authorization: Bearer rnk_your_key'Response
A JSON array of closure dates in the shape shown above.
Errors
404if your account is not part of any organisation.
GET /api/v1/closure-dates/check
Section titled “GET /api/v1/closure-dates/check”Check whether a specific date is a closure date for your organisation.
Any organisation member can call this.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| date | ISO 8601 date | Yes | The date to check. |
Example request
curl 'https://api.runnit.io/api/v1/closure-dates/check?date=2026-12-25' \ -H 'Authorization: Bearer rnk_your_key'Response
{ "isClosureDate": true }Errors
400ifdateis missing or not a valid date.
GET /api/v1/closure-dates/:id
Section titled “GET /api/v1/closure-dates/:id”Get a single closure date by ID.
Any organisation member can call this, for closure dates in their own organisation.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Closure date ID. |
Example request
curl https://api.runnit.io/api/v1/closure-dates/<closure-date-id> \ -H 'Authorization: Bearer rnk_your_key'Response
A single closure date object in the shape shown above.
Errors
404if the closure date does not exist or belongs to a different organisation.
POST /api/v1/closure-dates
Section titled “POST /api/v1/closure-dates”Create a closure date.
Requires permission to manage organisation settings (organisation admins and owners).
Request body
| Name | Type | Required | Description |
|---|---|---|---|
| date | ISO 8601 date | Yes | The closure date. |
| name | string | Yes | Label, for example “Christmas Day”. |
| description | string | No | Extra detail. |
| type | string | No | holiday, closure, or other. |
| isRecurring | boolean | No | Whether the closure repeats every year. |
Example request
curl -X POST https://api.runnit.io/api/v1/closure-dates \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{ "date": "2026-12-25", "name": "Christmas Day", "type": "holiday", "isRecurring": true }'Response
Returns 201 with the created closure date object.
Errors
400ifdateornameis missing, ortypeis invalid.403if you cannot manage organisation settings.404if your account is not part of any organisation.
POST /api/v1/closure-dates/bulk
Section titled “POST /api/v1/closure-dates/bulk”Create several closure dates in one request, for example a full public holiday calendar.
Requires permission to manage organisation settings (organisation admins and owners).
Request body
| Name | Type | Required | Description |
|---|---|---|---|
| dates | array | Yes | At least one entry. |
| dates[].date | ISO 8601 date | Yes | The closure date. |
| dates[].name | string | Yes | Label. |
| dates[].description | string | No | Extra detail. |
| dates[].type | string | No | holiday, closure, or other. |
| dates[].isRecurring | boolean | No | Whether the closure repeats every year. |
Example request
curl -X POST https://api.runnit.io/api/v1/closure-dates/bulk \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{ "dates": [ { "date": "2026-12-25", "name": "Christmas Day", "type": "holiday", "isRecurring": true }, { "date": "2026-12-28", "name": "Boxing Day (observed)", "type": "holiday", "isRecurring": false } ] }'Response
Returns 201 with a JSON array of the created closure date objects.
Errors
400ifdatesis empty, or any entry is missingdateorname.403if you cannot manage organisation settings.
PATCH /api/v1/closure-dates/:id
Section titled “PATCH /api/v1/closure-dates/:id”Update a closure date. Send only the fields you want to change.
Requires permission to manage organisation settings (organisation admins and owners).
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Closure date ID. |
Request body (all optional)
| Name | Type | Required | Description |
|---|---|---|---|
| date | ISO 8601 date | No | New date. |
| name | string | No | New label. |
| description | string | No | Extra detail. |
| type | string | No | holiday, closure, or other. |
| isRecurring | boolean | No | Whether the closure repeats every year. |
Example request
curl -X PATCH https://api.runnit.io/api/v1/closure-dates/<closure-date-id> \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{ "isRecurring": false }'Response
The updated closure date object.
Errors
400if a field fails validation.403if you cannot manage organisation settings.404if the closure date does not exist or belongs to a different organisation.
DELETE /api/v1/closure-dates/:id
Section titled “DELETE /api/v1/closure-dates/:id”Delete a closure date.
Requires permission to manage organisation settings (organisation admins and owners).
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Closure date ID. |
Example request
curl -X DELETE https://api.runnit.io/api/v1/closure-dates/<closure-date-id> \ -H 'Authorization: Bearer rnk_your_key'Response
Returns 204 with no body.
Errors
403if you cannot manage organisation settings.404if the closure date does not exist or belongs to a different organisation.
Blocked dates
Section titled “Blocked dates”Blocked-out dates are days one user is unavailable: leave, appointments, or anything else. A block can cover the whole of each day or only part of it. The scheduler treats whole-day blocks like closure dates scoped to that person and never books work on them; a partially blocked day keeps working capacity equal to the organisation’s daily capacity minus the blocked hours. These endpoints let an external system, such as an HR platform, sync approved leave into Runnit, including half-day leave sent directly as hours, so capacity planning stays accurate. All blocked-date endpoints operate on your own organisation.
A blocked-date range looks like this:
{ "id": "5b7d20aa-...", "organizationId": "8c2d41b7-...", "userId": "d3a6f0c1-...", "startDate": "2027-09-06", "endDate": "2027-09-10", "reason": "Synced from HR", "type": "leave", "hoursUnavailable": null, "createdAt": "2027-08-30T00:00:00.000Z", "updatedAt": "2027-08-30T00:00:00.000Z", "createdById": "d3a6f0c1-..."}type is one of leave, appointment, other (the default). reason and
createdById can be null.
hoursUnavailable is null for a whole-day block. A number makes the block
partial: that many hours are unavailable on each day of the range. Multiple
partial blocks covering the same day add together, and when the remaining
capacity reaches zero the day behaves exactly like a whole-day block. Values
at or above the organisation’s daily capacity also behave as whole-day blocks.
The stored hours are never rewritten, so a later change to the organisation’s
daily capacity re-evaluates every partial day automatically.
Permissions. An API key acts as its owning user. Everyone can manage their own blocked dates. Reading another user’s blocked dates requires schedule view access; creating, changing, or removing another user’s requires schedule management access (managers and above by default). When someone else changes a user’s blocked dates, the affected user is notified and the change is recorded in the permission audit log.
Reason privacy. The reason field is private. It is returned only for
your own blocks, or when your key’s user has schedule management access;
otherwise it is null in responses.
Validation. startDate and endDate must be plain YYYY-MM-DD calendar
dates (timestamps are rejected), endDate must be on or after startDate,
the inclusive range can span at most 366 days, and the target user must be an
active member of your organisation. hoursUnavailable, when sent, must be
greater than 0 and less than 24.
Conflict resolution. Creating or extending a block over days that already
carry scheduled work does not fail. A whole-day block conflicts with any work
on its days; a partial block conflicts only on days where the user’s total
booked hours exceed the remaining capacity. The resolution field decides
what happens to conflicting work: flag (the default) leaves the bookings in
place and raises conflict warnings; replan moves the affected tasks so each
partially blocked day holds at most the remaining hours and the excess moves
to the next available working days, shifting dependent tasks with them and
notifying the assignees. Use the preview endpoint first when you want to
inspect the impact before writing.
GET /api/v1/blocked-dates
Section titled “GET /api/v1/blocked-dates”List blocked-date ranges, defaulting to the key’s own user.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
| userIds | string | No | Comma-separated user IDs. Omit for your own blocks. Other users require schedule view access. |
| startDate | ISO 8601 date | No | Only ranges that end on or after this date. |
| endDate | ISO 8601 date | No | Only ranges that start on or before this date. |
Example request
curl 'https://api.runnit.io/api/v1/blocked-dates?userIds=<user-uuid>&startDate=2027-09-01&endDate=2027-09-30' \ -H 'Authorization: Bearer rnk_your_key'Response
A JSON array of blocked-date ranges in the shape shown above. reason is null
unless the block is your own or your user has schedule management access.
Errors
403if you request another user’s blocks without schedule view access.
POST /api/v1/blocked-dates/preview
Section titled “POST /api/v1/blocked-dates/preview”Get a read-only conflict report for a prospective block without creating or
changing anything. Useful before a bulk leave sync, or before editing an
existing block (send excludeBlockId).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| userId | UUID | Yes | The user the block is for. |
| startDate | ISO 8601 date | Yes | First blocked day. |
| endDate | ISO 8601 date | Yes | Last blocked day, inclusive. |
| hoursUnavailable | number | No | Hours per day for a partial block. Omit for whole days. |
| excludeBlockId | UUID | No | When previewing an edit to an existing block, its ID. The preview then evaluates the values in this request as that block’s replacement instead of stacking them on top of the existing entry. |
Example request
curl -X POST https://api.runnit.io/api/v1/blocked-dates/preview \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{"userId": "<user-uuid>", "startDate": "2027-09-06", "endDate": "2027-09-10", "hoursUnavailable": 4}'Response
{ "conflicts": [ { "taskId": "4be2d871-...", "taskName": "Homepage concepts", "projectId": "91c52864-...", "projectName": "Solara website refresh", "dates": ["2027-09-06", "2027-09-07"], "hours": 9, "bookingState": "confirmed", "scheduleMode": "auto", "replannable": true, "overflowByDate": { "2027-09-06": 1, "2027-09-07": 0.5 } } ], "totalHours": 9, "affectedTaskCount": 1, "partialDayDetails": [ { "date": "2027-09-06", "blockedHours": 4, "remainingCapacity": 4, "bookedHours": 5, "overflowHours": 1 } ]}bookingState is tentative, held, or confirmed. replannable is false
when the task can’t be moved automatically.
For partial blocks, partialDayDetails breaks down each affected day: the
blocked hours, the remaining capacity, the user’s booked hours, and the hours
over the remaining capacity. Each conflict’s overflowByDate maps the dates
where that task contributes to the overflow to the hours over. Both fields are
absent for whole-day evaluations with no partial overflow.
Errors
400for invalid dates or a target who is not an active member.403if the target is another user and you lack schedule management access.
POST /api/v1/blocked-dates
Section titled “POST /api/v1/blocked-dates”Create a blocked-date range, then resolve any conflicts with scheduled work
according to resolution.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| userId | UUID | Yes | The user the block is for. Your own user unless you have schedule management access. |
| startDate | ISO 8601 date | Yes | First blocked day. |
| endDate | ISO 8601 date | Yes | Last blocked day, inclusive. Range spans at most 366 days. |
| reason | string | No | Free text, up to 500 characters. Private to the user and schedule managers. |
| type | string | No | leave, appointment, or other (default). |
| hoursUnavailable | number | No | Hours per day for a partial block, greater than 0 and less than 24. Omit or send null for whole days. For half-day leave, send the hours directly. |
| resolution | string | No | flag (default) or replan. |
Example request
curl -X POST https://api.runnit.io/api/v1/blocked-dates \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{"userId": "<user-uuid>", "startDate": "2027-09-06", "endDate": "2027-09-10", "type": "leave", "reason": "Synced from HR", "resolution": "replan"}'Response
Returns 201 with the created block and the outcome of conflict resolution:
{ "block": { "id": "5b7d20aa-...", "startDate": "2027-09-06", "...": "..." }, "replan": { "replanned": [ { "taskId": "4be2d871-...", "taskName": "Homepage concepts", "fromStart": "2027-09-06", "toStart": "2027-09-13", "scheduledPastDueDate": false } ], "cascaded": { "shifted": [], "skipped": [], "evaluated": 1 }, "flagged": [], "errors": [], "residualConflicts": [] }}With resolution: "flag" (or when it is omitted), the response carries a
conflicts report in the preview shape instead of replan. When there is no
scheduled work on the dates, both are empty reports.
If the block is created but conflict resolution fails, the response includes a
resolutionError message instead. The block exists at that point: retry with
POST /api/v1/blocked-dates/:id/replan rather than re-creating it, which
would duplicate the block.
Errors
400for invalid dates, a range over 366 days, or a target who is not an active organisation member.403if the target is another user and you lack schedule management access.
POST /api/v1/blocked-dates/:id/replan
Section titled “POST /api/v1/blocked-dates/:id/replan”Retry replanning whatever still sits on this block’s dates. Idempotent: safe
to call again after a partial replan failure or a resolutionError.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Blocked-date range ID. |
Example request
curl -X POST https://api.runnit.io/api/v1/blocked-dates/<block-id>/replan \ -H 'Authorization: Bearer rnk_your_key'Response
{ "block": ..., "replan": ... } with the replan result in the shape shown
above.
Errors
403if the block belongs to another user and you lack schedule management access.404if the block does not exist or belongs to a different organisation.
PATCH /api/v1/blocked-dates/:id
Section titled “PATCH /api/v1/blocked-dates/:id”Update a blocked-date range. Send only the fields you want to change. Days
where the block became stricter (newly covered days, days that changed from
partial to whole, or partial hours that increased) are put through conflict
resolution using resolution (default flag); loosening a block frees
capacity without re-resolving anything. A resolution failure is reported as
resolutionError on the response, not as a failed update, and can be retried
with the replan endpoint.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
| startDate | ISO 8601 date | No | New first day. |
| endDate | ISO 8601 date | No | New last day, inclusive. |
| reason | string | No | Replacement reason, up to 500 characters. |
| type | string | No | leave, appointment, or other. |
| hoursUnavailable | number | No | Omit to leave the block’s hours unchanged. Send a number (greater than 0, less than 24) to set a partial block, or an explicit null to convert the block to whole days. |
| resolution | string | No | flag (default) or replan for newly covered dates. |
Example request
curl -X PATCH https://api.runnit.io/api/v1/blocked-dates/<block-id> \ -H 'Authorization: Bearer rnk_your_key' \ -H 'Content-Type: application/json' \ -d '{"endDate": "2027-09-14", "resolution": "replan"}'Response
{ "block": ... } with the updated range, plus conflicts, replan, or
resolutionError when the change newly covered scheduled days.
Errors
400for invalid dates or a range over 366 days.403if the block belongs to another user and you lack schedule management access.404if the block does not exist or belongs to a different organisation.
DELETE /api/v1/blocked-dates/:id
Section titled “DELETE /api/v1/blocked-dates/:id”Delete a blocked-date range. The days become available for scheduling again, but work that was replanned away does not move earlier automatically; replan the affected tasks if the freed time should be used.
Path parameters
| Name | Type | Required | Description |
|---|---|---|---|
| id | UUID | Yes | Blocked-date range ID. |
Example request
curl -X DELETE https://api.runnit.io/api/v1/blocked-dates/<block-id> \ -H 'Authorization: Bearer rnk_your_key'Response
Returns 204 with no body.
Errors
403if the block belongs to another user and you lack schedule management access.404if the block does not exist or belongs to a different organisation.
Next steps
Section titled “Next steps”Schedules are built from task assignments. Create and reschedule tasks through the Projects API, and update task status through the Tasks API.