# Scheduling

> Read days, find free time, book work into it, and undo changes with the Reassign REST API.

Source: https://reassign.dev/reference/scheduling/

Use the scheduling routes to read one or more days, to find conflict-free time for new work, and to undo a recent change. The server calculates the free slots for you.

The machine-readable spec of all public endpoints: [openapi.json](https://reassign.dev/openapi.json).

## How scheduling works

1. Read the days with `GET /schedule`. The response gives the events, the free slots, and the load by area, activity type, and project.
2. Book work with `POST /schedule/plan`. Each request gives a name, a duration, and an exact start or a time window.
3. A successful request books at once. An exact `start` books there when it is free; a window books its top-ranked free slot.
4. Undo a change with `POST /actions/undo` and the `undoToken` of that change.

| Item        | Lifetime                                                      | Use                                                                 |
| ----------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| `undoToken` | 30 minutes. The response gives the exact time in `expiresAt`. | Send it to `POST /actions/undo`. You can use a token one time only. |
| `requestId` | 60 seconds                                                    | A retry key for one request of `POST /schedule/plan`.               |

> `POST /schedule/plan` writes events immediately. To choose a time before booking, read `GET /schedule` and select a span from `freeSlots`. Keep the `undoToken` of each call that books an event.

MCP clients use `get_schedule` to read free slots, then `write_events` to create an event. Refer to [Schedule new work](https://reassign.dev/mcp.md#schedule-new-work).

### Scopes

| Route                 | Scope                           |
| --------------------- | ------------------------------- |
| `GET /schedule`       | `events:read` or `events:write` |
| `POST /schedule/plan` | `events:write`                  |
| `POST /actions/undo`  | `events:write`                  |

All routes need a trial or Pro plan. Refer to [Authentication](https://reassign.dev/authentication.md).

### Rows and status codes of the write routes

The two write routes return `results`, with one row for each item. Each row has `index` (0-based), `status` (`ok`, `error`, or `skipped`), and `result` or `error`. An `error` is `{ code, message }`, with optional detail fields. Refer to [Error format](https://reassign.dev/errors.md#error-format). A `skipped` row has a `reason`.

- Plan is atomic by default. When one row fails, the server writes nothing. Each other row is `skipped`, and the call has no `undoToken`.
- Send `partial: true` to plan to write the rows that succeed. The failed rows are errors.
- Undo has no `partial` flag. Each token succeeds or fails alone.
- When one or more rows are `ok`, the status is `200`. The body has `results`. For plan, it also has `timezone`, and `undoToken` with `expiresAt` when the server recorded an undo receipt. For undo, it has `results` only.
- When no row is `ok`, Reassign rejects the call. The body is the error envelope and `results`. The status follows `error.code`, as the Responses table of each route shows.
- A body that is not valid gets `422` with the code `validation` and no `results`.
- A plan row that replays an earlier call (refer to `requestId`) stays `ok` with `replayed: true`, also when another row fails. Thus the call is not rejected.
- A missing or empty array (`requests` or `tokens`) returns 422. More than 20 items also returns 422.

For the error codes, refer to [Errors](https://reassign.dev/errors.md).

---

## Read a schedule

**GET** `/api/v1/schedule`

Read one day or a range of days. The response gives the events, the free slots, and the load of each day. It also gives your areas, activity types, projects, and preferences. Scope: `events:read`.

- `from` and `to` are required. For one day, send the same date in both.
- `to` must not be before `from`. The range is 366 days or less. Else the server returns 422.
- Send each boolean parameter as `true` or `false`. Other values return 422.
- `includeBacklog=true` adds the Inbox items in `backlog`. `backlogQuery`, `backlogPlannedOn`, `backlogProjectId`, `backlogMinPriority`, and `backlogOffset` work only with `includeBacklog=true`. Without it, they return 422. Refer to [Backlog](https://reassign.dev/reference/backlog.md).
- `backlogProjectId` keeps the Inbox items of one project. Send a project id, or `none` for the items with no project.
- `backlogMinPriority` keeps the Inbox items with a priority of this value or more, from 0 to 4.
- The Inbox filters work together. With one or more filters, the response also has `backlogMatchedCount`.
- `includeSeries=true` adds the recurring series in `series`. Refer to [Series rows](https://reassign.dev/reference/scheduling.md#series-rows).
- `includeLookups=false` removes `areas`, `activityTypes`, `projects`, and `integrations` from the response. Use it when you have them from an earlier read. The default is `true`. `userPreferences` and `timezone` stay.
- `minDuration` is an optional whole number from 1 to 1440, in minutes. For example, `minDuration=90` returns only `freeSlots` of at least 90 minutes. It keeps the full span of each qualifying gap and does not filter events.

The MCP tool `get_schedule` has separate [weather and energy options](https://reassign.dev/mcp.md#weather-and-energy). For REST, read those reports through [GET /weather and GET /energy](https://reassign.dev/reference/energy-and-weather.md). The optional `energy` field in the shared response schema is used by MCP only.

### What the response contains

- `timezone` is the IANA time zone of the account. All `start` and `end` values are local datetimes in this time zone.
- `now` is the current local datetime, for example `"2026-09-23T14:05"`.
- `days` has one item for each date. Each day has `events` (the [event model](https://reassign.dev/reference/events.md)) and `freeSlots` (each a `start` and `end`). On today, the first free slot starts on the first 5-minute mark that is more than 5 minutes after `now`. A write accepts that start.
- Each occurrence of a series has `seriesId` and `originalDate`. Refer to [Ids of recurring events](https://reassign.dev/reference/events.md#ids-of-recurring-events).
- `loadByArea` and `loadByActivityType` give the time of the blocking events of the day. Each bucket is `areaId` (or `activityTypeId`), `minutes`, and `eventCount`. `areaId` is `null` for the events with no area.
- `loadByProject` gives the same buckets by `projectId`. `projectId` is `null` for the events with no project. The buckets start with the most minutes.
- A read of more than one day also has a top-level `loadByProject`: the sum of the `loadByProject` buckets of all days. An event on two days (across midnight) counts one time in `eventCount`.
- `projects` lists your projects: `id`, `name`, and `areaId` (or `null`). An archived project also has `archived: true`. It keeps its links, but it refuses new links. Refer to [Categories](https://reassign.dev/reference/categories.md).
- `nonBlockingLoadByArea` and `nonBlockingLoadByActivityType` give the same buckets for the events of the kind `non_blocking`. The `reference` events are not in them. A day with no `non_blocking` event does not have these fields.
- A day that has a confirmed review also has `review`: `reviewedAt` (an ISO 8601 instant) and the `adherence` scores. A score can be `null`. Refer to [Day reviews](https://reassign.dev/reference/day-reviews.md).
- `userPreferences.conflictPolicy` is always `propose`. A write that overlaps a blocking event fails with `conflict` and gives `nearestSlots`. No setting changes this.
- `integrations` is in the response when a calendar is connected. Every caller gets `integrations.sources[].account`, the account name at the provider. `integrations.sources[].calendars[].timezone` is the time zone of the calendar at the provider. It is for information only: all spans use the top-level `timezone`. `integrations.defaultCalendarId` is the default calendar of a new event, and the default Source of a new Inbox item. It is absent when the default calendar is not writable or its source is not connected. `backlogCount` is in the response when the Inbox is not empty. With `includeBacklog=true`, it is always there.
- A writable event calendar in `integrations.sources[].calendars[]` also has `copyStyle`: `full`, `private`, or `busy`. It is the style of a copy on that calendar when the `mirrorStyles` of the event does not name the calendar. With `copyStyle: "busy"`, `busyCopyTitle` is the title of the copy. `null` means "Busy". Refer to [Copy styles](https://reassign.dev/reference/events.md#copy-styles).
- `integrations.aiRuleWarnings` lists the problems in the AI rules of the user. Each item is an area or an activity type that the rules name. It does not exist, or its name matches more than one category. It has up to 20 items `{ code, field, name }`. `code` is `missing_taxonomy_reference` or `ambiguous_taxonomy_reference`. `field` is `area` or `activityType`. The field is absent when there is no warning.

### Series rows

With `includeSeries=true`, `series` has one row for each recurring series that obeys one of these conditions:

- The series has an occurrence in the range from `from` to `to`.
- The series has a `nextOccurrence`.
- The series has no end yet. A series with an open rule never ends, also when its next date is more than 366 days ahead.

A series ends when its `UNTIL` or `COUNT` leaves no date on or after today. The response does not include a series that ended outside the range.

| Field                                                         | Value                                                                                                                                                                                   |
| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `name`, `recurrence`                                    | The series id, its name, and its RRULE                                                                                                                                                  |
| `start`, `end`                                                | The anchor span of the series                                                                                                                                                           |
| `nextOccurrence`                                              | The first date from today to 366 days ahead with an occurrence that has not ended at `now`. A moved occurrence counts on its actual date, not the original date in its id. Else `null`. |
| `overrides`                                                   | `{ edited, deleted }`: the occurrences that you changed or deleted                                                                                                                      |
| `areaId`, `activityTypeId`                                    | The id, or `null`                                                                                                                                                                       |
| `kind`, `source`                                              | As on an event                                                                                                                                                                          |
| `calendarId`, `mirrorCalendarIds`, `mirrorStyles`, `readOnly` | As on an event, when they apply                                                                                                                                                         |

A series row has no `notes`. The events of the series have them.

### Query parameters

- `from` (_string, required_): First day of the range, inclusive.
- `to` (_string, required_): Last day of the range, inclusive.
- `includeSeries` (_"true" | "false"_): Also return the recurring masters as `series`.
- `includeLookups` (_"true" | "false"_): Default true. Pass false to leave out `areas`, `activityTypes`, `projects` and `integrations` when you already have them from an earlier read.
- `minDuration` (_string_): Return only the `freeSlots` of at least this many minutes.
- `includeBacklog` (_"true" | "false"_): Include the Inbox items (the `backlog` array), not just `backlogCount`. Pass true when the user wants to see, schedule, or manage their Inbox.
- `backlogQuery` (_string_): Filter the Inbox blocks by name (fuzzy, case/accent-insensitive), e.g. "dentist". Needs includeBacklog: true.
- `backlogPlannedOn` (_string_): Filter the Inbox blocks to those planned for this day (a planned day, or a planned window that covers it). Needs includeBacklog: true; composes with backlogQuery.
- `backlogProjectId` (_string_): Filter the Inbox blocks to one project id, or "none" for the blocks with no project. Needs includeBacklog: true; composes with the other filters.
- `backlogMinPriority` (_string_): Filter the Inbox blocks to a priority of at least this value (0 none, 1 low, 2 medium, 3 high, 4 urgent). Needs includeBacklog: true; composes with the other filters.
- `backlogOffset` (_string_): Inbox page offset from nextBacklogOffset; keep the same filters. Needs includeBacklog: true.

### Response fields

- `timezone` (_string, required_)
- `now` (_string, required_)
- `weather` (_string_)
- `energy` (_string_)
- `days` (_object\[], required_)
  - `date` (_string, required_)
  - `events` (_object\[], required_)
    - `start` (_string, required_)
    - `end` (_string, required_)
    - `id` (_string, required_)
    - `seriesId` (_string_)
    - `originalDate` (_string_)
    - `kind` (_"blocking" | "non\_blocking" | "reference", required_)
    - `name` (_string, required_)
    - `notes` (_string_)
    - `source` (_"google" | "google\_tasks" | "linear" | "microsoft" | "microsoft\_todo" | "ticktick" | "todoist" | "reassign", required_)
    - `sourceUrl` (_string_)
    - `meeting` (_object_)
      - `url` (_string, required_)
      - `label` (_string, required_)
    - `location` (_object_)
      - `text` (_string, required_)
      - `url` (_string_)
    - `recurrence` (_string_)
    - `warning` (_string_)
    - `areaId` (_string | null, required_)
    - `activityTypeId` (_string | null, required_)
    - `projectId` (_string | null, required_)
    - `priority` (_integer, required_)
    - `calendarId` (_string | null_)
    - `mirrorCalendarIds` (_string\[]_)
    - `mirrorStyles` (_map of "full" | "private" | "busy"_)
    - `readOnly` (_true_)
    - `focusIntervals` (_object_)
      - `focusMin` (_integer, required_)
      - `breakMin` (_integer, required_)
    - `completedFocusIntervals` (_integer_)
    - `checklist` (_object_)
      - `items` (_object\[], required_)
        - `id` (_string, required_)
        - `text` (_string, required_)
        - `done` (_boolean, required_)
    - `reflect` (_object_)
      - One form: Without `actualStart`, `actualEnd`
        - `status` (_"kept" | "skipped" | "changed" | "added", required_)
      - One form: With `actualStart`, `actualEnd`
        - `status` (_"changed", required_)
        - `actualStart` (_string, required_)
        - `actualEnd` (_string, required_)
  - `freeSlots` (_object\[], required_)
    - `start` (_string, required_)
    - `end` (_string, required_)
  - `loadByArea` (_object\[], required_)
    - `areaId` (_string | null, required_)
    - `minutes` (_integer, required_)
    - `eventCount` (_integer, required_)
  - `loadByActivityType` (_object\[], required_)
    - `activityTypeId` (_string | null, required_)
    - `minutes` (_integer, required_)
    - `eventCount` (_integer, required_)
  - `loadByProject` (_object\[], required_)
    - `projectId` (_string | null, required_)
    - `minutes` (_integer, required_)
    - `eventCount` (_integer, required_)
  - `nonBlockingLoadByArea` (_object\[]_)
    - `areaId` (_string | null, required_)
    - `minutes` (_integer, required_)
    - `eventCount` (_integer, required_)
  - `nonBlockingLoadByActivityType` (_object\[]_)
    - `activityTypeId` (_string | null, required_)
    - `minutes` (_integer, required_)
    - `eventCount` (_integer, required_)
  - `review` (_object_)
    - `reviewedAt` (_string, required_)
    - `adherence` (_object_)
      - `event` (_number | null, required_)
      - `layer` (_number | null, required_)
      - `plannedMinutes` (_integer, required_)
      - `unplannedMinutes` (_integer, required_)
      - `byArea` (_object\[], required_)
        - `areaId` (_string | null, required_)
        - `adherence` (_number, required_)
        - `plannedMinutes` (_integer, required_)
      - `byActivityType` (_object\[], required_)
        - `activityTypeId` (_string | null, required_)
        - `adherence` (_number, required_)
        - `plannedMinutes` (_integer, required_)
- `loadByProject` (_object\[]_)
  - `projectId` (_string | null, required_)
  - `minutes` (_integer, required_)
  - `eventCount` (_integer, required_)
- `areas` (_object\[]_)
  - `id` (_string, required_)
  - `name` (_string, required_)
  - `color` (_string, required_)
- `activityTypes` (_object\[]_)
  - `id` (_string, required_)
  - `name` (_string, required_)
  - `pattern` (_"solid" | "hatch\_r" | "hatch\_l" | "cross" | "horizontal" | "vertical" | "grid" | "dots" | "waves" | "chevron", required_)
- `projects` (_object\[]_)
  - `id` (_string, required_)
  - `name` (_string, required_)
  - `areaId` (_string | null, required_)
  - `archived` (_true_): Present on an archived project: it refuses new links, and an update with archived: false restores it.
- `userPreferences` (_object, required_)
  - `conflictPolicy` (_"propose", required_): Always `propose`: a write that collides is refused with free slots.
- `integrations` (_object_)
  - `aiClassify` (_boolean, required_)
  - `aiRules` (_string_)
  - `aiRuleWarnings` (_object\[]_)
    - `code` (_"missing\_taxonomy\_reference" | "ambiguous\_taxonomy\_reference", required_)
    - `field` (_"area" | "activityType", required_)
    - `name` (_string, required_)
  - `defaultCalendarId` (_string_)
  - `sources` (_object\[], required_)
    - `provider` (_"google" | "google\_tasks" | "linear" | "microsoft" | "microsoft\_todo" | "ticktick" | "todoist", required_)
    - `account` (_string_)
    - `status` (_"connected" | "disconnected" | "error" | "pending" | "revoked", required_)
    - `calendars` (_object\[], required_)
      - `id` (_string, required_)
      - `name` (_string, required_)
      - `writable` (_boolean, required_)
      - `area` (_object, required_)
        - One form: `mode: "automatic"`

        - One form: `mode: "fixed"`
          - `areaId` (_string, required_)
      - `activityType` (_object, required_)
        - One form: `mode: "automatic"`

        - One form: `mode: "fixed"`
          - `activityTypeId` (_string, required_)
        - One form: `mode: "none"`

      - `kind` (_object, required_)
        - One form: `mode: "automatic"`

        - One form: `mode: "fixed"`
          - `kind` (_"blocking" | "non\_blocking" | "reference", required_)
      - `timezone` (_string_)
      - `copyStyle` (_"full" | "private" | "busy"_)
      - `busyCopyTitle` (_string | null_)
- `series` (_object\[]_)
  - `start` (_string, required_)
  - `end` (_string, required_)
  - `id` (_string, required_)
  - `name` (_string, required_)
  - `recurrence` (_string, required_)
  - `nextOccurrence` (_string | null, required_)
  - `overrides` (_object, required_)
    - `edited` (_integer, required_)
    - `deleted` (_integer, required_)
  - `areaId` (_string | null, required_)
  - `activityTypeId` (_string | null, required_)
  - `kind` (_"blocking" | "non\_blocking" | "reference", required_)
  - `source` (_"google" | "google\_tasks" | "linear" | "microsoft" | "microsoft\_todo" | "ticktick" | "todoist" | "reassign", required_)
  - `calendarId` (_string | null_)
  - `mirrorCalendarIds` (_string\[]_)
  - `mirrorStyles` (_map of "full" | "private" | "busy"_)
  - `readOnly` (_true_)
- `backlogCount` (_integer_)
- `backlogMatchedCount` (_integer_)
- `nextBacklogOffset` (_integer | null_)
- `backlog` (_object\[]_)
  - `id` (_string, required_)
  - `name` (_string, required_)
  - `notes` (_string_)
  - `sourceUrl` (_string_)
  - `kind` (_"blocking" | "non\_blocking" | "reference", required_)
  - `durationMinutes` (_integer_)
  - `areaId` (_string | null, required_)
  - `activityTypeId` (_string | null, required_)
  - `projectId` (_string | null, required_)
  - `priority` (_integer, required_)
  - `plannedDate` (_string_)
  - `plannedUntil` (_string_)
  - `checklist` (_object_)
    - `items` (_object\[], required_)
      - `id` (_string, required_)
      - `text` (_string, required_)

### Responses

| Status | Codes                 | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`  | —                     | Success.                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `401`  | `unauthorized`        | No Bearer token and no session cookie, or the token is invalid, expired, or revoked. The response carries a `WWW-Authenticate` challenge that points at the protected-resource metadata.                                                                                                                                                                                                                                                                                               |
| `403`  | `permission`, `scope` | A token without `events:read` or `events:write` gets `scope` "This token is not authorized to read events (requires events:read or events:write scope)." An account without Reassign Pro access gets `permission` "Reassign needs an active subscription." The 7-day free trial includes Reassign Pro. A cookie session from another site gets `permission` "Cross-origin request."                                                                                                    |
| `422`  | `validation`          | A query parameter is not valid. The message names the first bad field by its path. A repeated or unknown query parameter is also a 422.                                                                                                                                                                                                                                                                                                                                                |
| `429`  | `rate_limited`        | Too many requests. The edge firewall limits each IP to 2000 requests per minute across `/api/v1`; that 429 comes from the edge and can have a body that is not this JSON and no `Retry-After` header. When an edge 429 has no `Retry-After`, retry after 60 seconds. The app also limits each Bearer credential to 600 requests per minute, and some routes limit each user. These limits return this body. This app 429 always has `Retry-After`: retry after that number of seconds. |
| `503`  | `internal`            | A fault on our side. The request is safe to retry.                                                                                                                                                                                                                                                                                                                                                                                                                                     |

**Request** — **GET** `/api/v1/schedule`

**cURL**

```bash
curl "https://reassign.app/api/v1/schedule?from=2026-08-14&to=2026-08-14" \
  -H "Authorization: Bearer rsk_live_YOUR_TOKEN"
```

**JavaScript**

```js
const res = await fetch("https://reassign.app/api/v1/schedule?from=2026-08-14&to=2026-08-14", {
  headers: {
    Authorization: "Bearer rsk_live_YOUR_TOKEN",
  },
});
const data = await res.json();
```

**Response**

```json
{
  "timezone": "Europe/Ljubljana",
  "now": "2026-08-14T10:42",
  "days": [
    {
      "date": "2026-08-14",
      "events": [
        {
          "id": "b7f1c2a0-1d2e-4f3a-9b8c-5d6e7f801234",
          "start": "2026-08-14T09:00",
          "end": "2026-08-14T10:30",
          "kind": "blocking",
          "name": "Team sync",
          "source": "google",
          "meeting": {
            "url": "https://us02web.zoom.us/j/1234567890",
            "label": "Zoom"
          },
          "location": {
            "text": "Room 4, 2nd floor",
            "url": "https://www.google.com/maps/search/?api=1&query=Room%204%2C%202nd%20floor"
          },
          "areaId": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
          "activityTypeId": "5e6f7081-92a3-4b4c-8d5e-6f708192a3b4",
          "projectId": null,
          "priority": 0,
          "calendarId": "8a1f0c2e-3b4d-4e5f-9a0b-1c2d3e4f5061",
          "mirrorCalendarIds": [
            "a1b2c3d4-5e6f-4071-8293-a4b5c6d7e8f9"
          ]
        }
      ],
      "freeSlots": [
        {
          "start": "2026-08-14T10:45",
          "end": "2026-08-15T00:00"
        }
      ],
      "loadByArea": [
        {
          "areaId": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
          "minutes": 90,
          "eventCount": 1
        }
      ],
      "loadByActivityType": [
        {
          "activityTypeId": "5e6f7081-92a3-4b4c-8d5e-6f708192a3b4",
          "minutes": 90,
          "eventCount": 1
        }
      ],
      "loadByProject": [
        {
          "projectId": null,
          "minutes": 90,
          "eventCount": 1
        }
      ]
    }
  ],
  "areas": [
    {
      "id": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
      "name": "Work",
      "color": "#3b82f6"
    }
  ],
  "activityTypes": [
    {
      "id": "5e6f7081-92a3-4b4c-8d5e-6f708192a3b4",
      "name": "Focus",
      "pattern": "solid"
    }
  ],
  "projects": [],
  "userPreferences": {
    "conflictPolicy": "propose"
  },
  "integrations": {
    "aiClassify": true,
    "defaultCalendarId": "8a1f0c2e-3b4d-4e5f-9a0b-1c2d3e4f5061",
    "sources": [
      {
        "provider": "google",
        "account": "user@example.com",
        "status": "connected",
        "calendars": [
          {
            "id": "8a1f0c2e-3b4d-4e5f-9a0b-1c2d3e4f5061",
            "name": "Work",
            "writable": true,
            "area": {
              "mode": "automatic"
            },
            "activityType": {
              "mode": "automatic"
            },
            "kind": {
              "mode": "automatic"
            }
          },
          {
            "id": "a1b2c3d4-5e6f-4071-8293-a4b5c6d7e8f9",
            "name": "Team",
            "writable": true,
            "area": {
              "mode": "automatic"
            },
            "activityType": {
              "mode": "automatic"
            },
            "kind": {
              "mode": "automatic"
            }
          }
        ]
      }
    ]
  },
  "backlogCount": 1
}
```

---

## Plan work into free time

**POST** `/api/v1/schedule/plan`

Place one or more events into free time. You do not calculate the slots. Send 1 to 20 `requests`. Scope: `events:write`.

The requests run in their order. A later request does not overlap an event that an earlier request booked.

The call is atomic by default. When one request fails, the server books nothing. Send `partial: true` to book the requests that succeed.

### Two forms of a request

Each request has `name` (1 to 200 characters) and `durationMinutes` (a whole number from 5 to 1440). It also has one of these two forms:

| Form        | Fields                  | What the server does                                                                                                                                                                                                                                                                                    |
| ----------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Fixed start | `start`                 | When the time is free, the server books the event. When the time overlaps a blocking event, or a request before it in the same call, the row fails with `conflict`. The row gives `conflicts` and up to 5 `nearestSlots` in the next 7 days. The server does not move an exact `start` to another time. |
| Window      | `earliest` and `latest` | The server searches the window. The window is at most 24 hours, and it can go past midnight. The server books the top-ranked free slot immediately. When no slot fits, the row fails with `conflict`.                                                                                                   |

- All times are local datetimes `"YYYY-MM-DDTHH:MM"`.
- `autoCommitBest` is deprecated. A window accepts `true` or an omitted field, with the same behavior. `false` fails with `validation`.
- A request with `start` and also `earliest`, `latest`, or `autoCommitBest` is an error.
- A request can also have `kind` and `recurrence`, as on an event `create`. A fixed-start request with `kind: "non_blocking"` or `kind: "reference"` does not fail with `conflict`. A blocking fixed-start request with `recurrence` fails with `conflict` when an occurrence overlaps a blocking event.
- A start or a window in the past is an error. On today, the start must be more than 5 minutes after now. A window with no room for a 5-minute slot after that point fails with `validation`, not `conflict`.
- A window slot starts on a 30-minute mark. When no 30-minute mark fits, it starts at the start of a free gap (a 5-minute mark).
- A nearest slot can start at one of these times: a 15-minute mark, the start of a free gap, or the latest start that fits in a free gap. It can also start at the free minute nearest to the requested start. It keeps the exact `durationMinutes`. This rule applies to each `nearestSlots` list, also in `POST /events` and `POST /backlog`.
- An unknown key fails the row with `validation`: `Unrecognized key(s): <keys>.` When the key belongs to the other form, the message names that form.
- `calendarId`, `mirrorCalendarIds`, and `mirrorStyles` work as on an event create. `null` keeps the event on the dial only. When you omit `calendarId`, the event goes to the default calendar if there is one. Else it stays on the dial only. A request with `recurrence` and `mirrorCalendarIds` needs a home calendar. Refer to [Rules of the fields](https://reassign.dev/reference/events.md#rules-of-the-fields).
- `mirrorStyles` sets the style of each copy. Each key must be in the `mirrorCalendarIds` of the request. `{}` is the same as no map. Refer to [Copy styles](https://reassign.dev/reference/events.md#copy-styles).
- `areaId` and `activityTypeId` take an id. `null` and an omitted key are the same. An id that does not exist, or that you cannot see, fails the row with `not_found`.
- `projectId` takes the id of a live project. `null` and an omitted key are the same. An empty string fails the row with `validation`. An archived or unknown id fails the row with `not_found`. In `result.event`, `projectId` is the stored form of the id, also when you send it braced or in upper case.
- With no `areaId`, the event takes the area of the project. An `areaId` in the request wins. When the area of the project is hidden, the event gets no area. Refer to [Rules of the fields](https://reassign.dev/reference/events.md#rules-of-the-fields).
- `priority` is a whole number: `0` none, `1` low, `2` medium, `3` high, `4` urgent. An omitted value is `0`. `null`, a number that is not whole, or a value out of the range fails the row with `validation`.
- The server checks `areaId` and `activityTypeId` before `projectId`. When a request has an unknown area and an unknown project, the row error names the area.

### Results

Each successful row has `event`, the booked event, in `result`.

- An exact-start `conflict` row gives `nearestSlots`. To take one, send its `start` as a new request.
- When the call books one or more events and records an undo receipt, the body has one `undoToken` and its `expiresAt`. This token undoes all bookings of the call. If the server cannot record the undo, the bookings stay and the response has no `undoToken`.

### Row errors

A request that is not valid has `status: "error"` and the code `validation`. In the default atomic mode, the other rows are `skipped`, and the server rejects the call. With `partial: true`, the other requests still run, and the call returns 200 when one or more rows are `ok`.

A `validation` row from a schema check also has `error.issues`. Later checks, such as a start in the past or a series with copies and no home calendar, can return `validation` without `issues`. Refer to [Validation errors](https://reassign.dev/errors.md#validation-errors-422).

A body that is not valid returns 422 with no `results`, for example when `requests` is empty or has more than 20 items.

Some row errors:

| Cause                                                                     | `error.message`                                                                                                                                  | `error.code` |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------ |
| A name of spaces only                                                     | `Event name cannot be blank.`                                                                                                                    | `validation` |
| A window longer than 24 hours                                             | ``The window from `earliest` to `latest` can be at most 24 hours.``                                                                              | `validation` |
| A window where `latest` is not after `earliest`                           | `` `latest` must be a local datetime after `earliest`. ``                                                                                        | `validation` |
| A request with `recurrence` and `mirrorCalendarIds`, and no home calendar | `Copies of a series need a home calendar. Give the series a calendarId, or remove its mirrorCalendarIds.`                                        | `validation` |
| A `mirrorStyles` key that is not in `mirrorCalendarIds`                   | `mirrorStyles has calendar "<id>", which is not in the event's mirrorCalendarIds. Add it to mirrorCalendarIds, or remove it from mirrorStyles.`  | `validation` |
| A fixed start in the past                                                 | `<start> is in the past. Pick a later time.`                                                                                                     | `validation` |
| A fixed start today, 5 minutes or less after now                          | `<start> is too soon: a start today must be more than 5 minutes after now. Pick a later time.`                                                   | `validation` |
| A window in the past, or with no room after now                           | `The window <earliest> to <latest> is in the past or starts too soon: a start today must be more than 5 minutes after now. Pick a later window.` | `validation` |
| A taken fixed start                                                       | `<start> is taken. Pick another time or one of the nearest free slots.`                                                                          | `conflict`   |
| A taken fixed start with no free slot in the next 7 days                  | `<start> is taken and no free <n>-minute slot fits through <date> (the next 7 days).`                                                            | `conflict`   |
| An unknown `areaId`                                                       | `Area "<id>" not found or not accessible.`                                                                                                       | `not_found`  |
| An unknown `activityTypeId`                                               | `Activity type "<id>" not found or not accessible.`                                                                                              | `not_found`  |
| An unknown or archived `projectId`                                        | `No live project with id "<id>".`                                                                                                                | `not_found`  |

When all rows fail with `not_found`, the status is `404`.

A window schema error does not repeat the values that you sent. When `earliest` or `latest` has a bad format, its issue has `expected: "YYYY-MM-DDTHH:MM"`; the schema skips the window-order and length checks until both values have valid formats. Later errors, such as a window in the past or a window with no free slot, include the window's times.

### Retries with `requestId`

Give each request a `requestId`. When you send the same `requestId` again in 60 seconds, the server does not book a second event. It returns the first result with `replayed: true`, and it writes nothing new.

- The retry must send the same content. A request with different content is a new request.
- `mirrorStyles` is content. The order of the keys in `mirrorStyles` does not change the match. `{}` and no map match. The order of `mirrorCalendarIds` changes the match.
- `projectId` and `priority` are content. A retry with another priority is a new request.
- `priority: 0`, `projectId: null`, `areaId: null`, and `activityTypeId: null` match an omitted key. `priority: null` is not valid.
- The server compares the ids as you sent them. Send each id in the same form in a retry.
- Only a booked result can replay. The server does not replay a result that you undid.
- Omitting `autoCommitBest` or sending `true` does not change retry matching. Clients can drop the deprecated field while keeping the same `requestId`.
- Only a `requestId` replays a result. A request with no `requestId` is always a new request.
- When one call sends the same `requestId` in more than one request, the server replays an earlier result for one request only. The other requests run as new requests.

### Request body

- `requests` (_object\[], required_)
  - One form: With `start`. An exact request: book at `start`.
    - `name` (_string, required_): Event title, e.g. "Deep work", "Gym".
    - `durationMinutes` (_integer, required_): How long, in whole minutes, e.g. 90.
    - `areaId` (_string | null_): Area id to attach (from the schedule read's `areas`); null or omitted leaves it unset.
    - `activityTypeId` (_string | null_): Activity-type id to attach (from the schedule read's `activityTypes`); null or omitted leaves it unset.
    - `projectId` (_string | null_): A live project id from get\_schedule `projects`. When the project changes and the op sets no areaId, the event takes the area of the project. null or omitted leaves it unset.
    - `priority` (_integer_): 0 none, 1 low, 2 medium, 3 high, 4 urgent. Omit for 0.
    - `recurrence` (_string_): Repeat rule as an RRULE, e.g. "FREQ=WEEKLY;BYDAY=MO"; omit for a one-off.
    - `kind` (_"blocking" | "non\_blocking" | "reference"_): "blocking" (default) cannot overlap others; "non\_blocking" is an overlay (e.g. a fasting band or an away/OOO marker) that may overlap and never conflicts; "reference" is something the user wants to SEE but is not doing — non-blocking, and don't move, delete, or schedule work into it unless asked. Note a non-blocking request still SEARCHES for a free slot like any other; it simply does not occupy one once placed.
    - `notes` (_string_)
    - `sourceUrl` (_string_): Where this was captured from — the http(s) page address. Shown as a source chip; never parsed. Set it only for something grabbed off a real page, not for an intention the user typed.
    - `calendarId` (_string | null_): The home calendar: a calendar id (from the calendars list or the schedule's `integrations`). Omit it to use the account's default calendar if one is set, else the event stays on the dial only. null keeps the event on the dial only. A calendar id requires Reassign Pro; whole-series only.
    - `mirrorCalendarIds` (_string\[]_): The one-way copy calendars, as calendar ids. It must not contain the home `calendarId`. On update it REPLACES the set; \[] clears it. An id already on the event may stay even if its calendar is read-only now; only an added id is checked. Adding an id requires Reassign Pro; whole-series only.
    - `mirrorStyles` (_map of "full" | "private" | "busy"_): How much each copy shows, per copy calendar id: 'full' (default) copies the title and notes; 'private' keeps them but marks the copy private; 'busy' is a shield: a private copy with a neutral title (the calendar's busy title, else "Busy"), no notes, and no reminders. Each key must be in the event's mirrorCalendarIds. A calendar left out uses that calendar's default copy style. On update it REPLACES the map; {} clears it. Needs Reassign Pro; whole-series only.
    - `requestId` (_string_): Idempotency key; reuse on retry to avoid double-booking. Only this key replays: a request without it is always booked as new.
    - `start` (_string, required_): Exact local start "YYYY-MM-DDTHH:MM"; place there. A taken start is a `conflict` error with `nearestSlots`.
  - One form: With `earliest`, `latest`. A window request: search from `earliest` to `latest`, at most 24 hours.
    - `name` (_string, required_): Event title, e.g. "Deep work", "Gym".
    - `durationMinutes` (_integer, required_): How long, in whole minutes, e.g. 90.
    - `areaId` (_string | null_): Area id to attach (from the schedule read's `areas`); null or omitted leaves it unset.
    - `activityTypeId` (_string | null_): Activity-type id to attach (from the schedule read's `activityTypes`); null or omitted leaves it unset.
    - `projectId` (_string | null_): A live project id from get\_schedule `projects`. When the project changes and the op sets no areaId, the event takes the area of the project. null or omitted leaves it unset.
    - `priority` (_integer_): 0 none, 1 low, 2 medium, 3 high, 4 urgent. Omit for 0.
    - `recurrence` (_string_): Repeat rule as an RRULE, e.g. "FREQ=WEEKLY;BYDAY=MO"; omit for a one-off.
    - `kind` (_"blocking" | "non\_blocking" | "reference"_): "blocking" (default) cannot overlap others; "non\_blocking" is an overlay (e.g. a fasting band or an away/OOO marker) that may overlap and never conflicts; "reference" is something the user wants to SEE but is not doing — non-blocking, and don't move, delete, or schedule work into it unless asked. Note a non-blocking request still SEARCHES for a free slot like any other; it simply does not occupy one once placed.
    - `notes` (_string_)
    - `sourceUrl` (_string_): Where this was captured from — the http(s) page address. Shown as a source chip; never parsed. Set it only for something grabbed off a real page, not for an intention the user typed.
    - `calendarId` (_string | null_): The home calendar: a calendar id (from the calendars list or the schedule's `integrations`). Omit it to use the account's default calendar if one is set, else the event stays on the dial only. null keeps the event on the dial only. A calendar id requires Reassign Pro; whole-series only.
    - `mirrorCalendarIds` (_string\[]_): The one-way copy calendars, as calendar ids. It must not contain the home `calendarId`. On update it REPLACES the set; \[] clears it. An id already on the event may stay even if its calendar is read-only now; only an added id is checked. Adding an id requires Reassign Pro; whole-series only.
    - `mirrorStyles` (_map of "full" | "private" | "busy"_): How much each copy shows, per copy calendar id: 'full' (default) copies the title and notes; 'private' keeps them but marks the copy private; 'busy' is a shield: a private copy with a neutral title (the calendar's busy title, else "Busy"), no notes, and no reminders. Each key must be in the event's mirrorCalendarIds. A calendar left out uses that calendar's default copy style. On update it REPLACES the map; {} clears it. Needs Reassign Pro; whole-series only.
    - `requestId` (_string_): Idempotency key; reuse on retry to avoid double-booking. Only this key replays: a request without it is always booked as new.
    - `earliest` (_string, required_): Start of the local "YYYY-MM-DDTHH:MM" window to search.
    - `latest` (_string, required_): End of the search window: after `earliest` and at most 24 hours later; it may be on the next day.
    - `autoCommitBest` (_true_): Deprecated: a window always books its top-ranked free slot. Accepted so that older clients still work.
- `partial` (_boolean_): Best-effort mode: apply the rows that succeed and report the rest as errors, instead of the default all-or-nothing.

### Response fields

- `timezone` (_string_)
- `undoToken` (_string_)
- `expiresAt` (_string_)
- `results` (_object\[], required_)
  - One form: `status: "ok"`
    - `index` (_integer, required_)
    - `result` (_object, required_)
      - `event` (_object, required_)
        - `start` (_string, required_)
        - `end` (_string, required_)
        - `id` (_string, required_)
        - `seriesId` (_string_)
        - `originalDate` (_string_)
        - `kind` (_"blocking" | "non\_blocking" | "reference", required_)
        - `name` (_string, required_)
        - `notes` (_string_)
        - `source` (_"google" | "google\_tasks" | "linear" | "microsoft" | "microsoft\_todo" | "ticktick" | "todoist" | "reassign", required_)
        - `sourceUrl` (_string_)
        - `meeting` (_object_)
          - `url` (_string, required_)
          - `label` (_string, required_)
        - `location` (_object_)
          - `text` (_string, required_)
          - `url` (_string_)
        - `recurrence` (_string_)
        - `warning` (_string_)
        - `areaId` (_string | null, required_)
        - `activityTypeId` (_string | null, required_)
        - `projectId` (_string | null, required_)
        - `priority` (_integer, required_)
        - `calendarId` (_string | null_)
        - `mirrorCalendarIds` (_string\[]_)
        - `mirrorStyles` (_map of "full" | "private" | "busy"_)
        - `readOnly` (_true_)
        - `focusIntervals` (_object_)
          - `focusMin` (_integer, required_)
          - `breakMin` (_integer, required_)
        - `completedFocusIntervals` (_integer_)
        - `checklist` (_object_)
          - `items` (_object\[], required_)
            - `id` (_string, required_)
            - `text` (_string, required_)
            - `done` (_boolean, required_)
        - `reflect` (_object_)
          - One form: Without `actualStart`, `actualEnd`
            - `status` (_"kept" | "skipped" | "changed" | "added", required_)
          - One form: With `actualStart`, `actualEnd`
            - `status` (_"changed", required_)
            - `actualStart` (_string, required_)
            - `actualEnd` (_string, required_)
      - `replayed` (_true_)
    - `warnings` (_"classification\_pending"\[]_)
  - One form: `status: "error"`
    - `index` (_integer, required_)
    - `error` (_object, required_)
      - `code` (_"conflict" | "not\_found" | "validation" | "permission" | "scope" | "stale" | "read\_only" | "rate\_limited" | "internal", required_)
      - `message` (_string, required_)
      - `issues` (_object\[]_)
        - `path` (_string | integer\[], required_)
        - `message` (_string, required_)
        - `expected` (_string_)
      - `feature` (_string_)
      - `conflictingRows` (_object\[]_)
        - `entity` (_string, required_)
        - `id` (_string, required_)
        - `reason` (_"changed" | "deleted" | "newly\_present", required_)
      - `conflictingRowsTruncated` (_true_)
      - `usage` (_object_)
        - `events` (_integer, required_)
        - `inboxItems` (_integer, required_)
        - `trackers` (_integer, required_)
        - `calendars` (_integer, required_)
        - `projects` (_integer_)
      - `reassignTargets` (_object\[]_)
        - `id` (_string, required_)
        - `name` (_string, required_)
      - `unlinkedCheckout` (_object_)
        - `sessionId` (_string, required_)
        - `paymentReceived` (_boolean, required_)
        - `supportUrl` (_string, required_)
      - `conflicts` (_object\[]_)
        - One form: With `id`
          - `start` (_string, required_)
          - `end` (_string, required_)
          - `id` (_string, required_)
          - `name` (_string, required_)
        - One form: With `batchIndex`
          - `start` (_string, required_)
          - `end` (_string, required_)
          - `batchIndex` (_integer, required_)
          - `name` (_string, required_)
      - `nearestSlots` (_object\[]_)
        - `start` (_string, required_)
        - `end` (_string, required_)
        - `score` (_number_)
        - `reason` (_string_)
  - One form: `status: "skipped"`
    - `index` (_integer, required_)
    - `reason` (_string, required_)

### Responses

| Status | Codes                 | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`  | —                     | Success.                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `400`  | `batch_rejected`      | Every op failed, and the failed ops have different codes. The code is `batch_rejected`.                                                                                                                                                                                                                                                                                                                                                                                                |
| `401`  | `unauthorized`        | No Bearer token and no session cookie, or the token is invalid, expired, or revoked. The response carries a `WWW-Authenticate` challenge that points at the protected-resource metadata.                                                                                                                                                                                                                                                                                               |
| `403`  | `permission`, `scope` | A token without `events:write` gets `scope` "This token is not authorized to modify events (missing events:write scope)." An account without Reassign Pro access gets `permission` "Reassign needs an active subscription." The 7-day free trial includes Reassign Pro. A cookie session from another site gets `permission` "Cross-origin request."                                                                                                                                   |
| `404`  | `not_found`           | Every request failed with the code `not_found`. The `areaId` or `activityTypeId` of each request names an area or an activity type that does not exist or that the caller cannot see, or its `projectId` names no live project of the caller.                                                                                                                                                                                                                                          |
| `409`  | `conflict`            | An op conflicts with the current state.                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `422`  | `validation`          | The request body is not valid. The message names the first bad field by its path. A repeated or unknown query parameter is also a 422.                                                                                                                                                                                                                                                                                                                                                 |
| `429`  | `rate_limited`        | Too many requests. The edge firewall limits each IP to 2000 requests per minute across `/api/v1`; that 429 comes from the edge and can have a body that is not this JSON and no `Retry-After` header. When an edge 429 has no `Retry-After`, retry after 60 seconds. The app also limits each Bearer credential to 600 requests per minute, and some routes limit each user. These limits return this body. This app 429 always has `Retry-After`: retry after that number of seconds. |
| `503`  | `internal`            | A fault on our side. The request is safe to retry.                                                                                                                                                                                                                                                                                                                                                                                                                                     |

Most error responses: When no op of the batch applied, the body also has the `results` rows, so the caller sees which op failed.

**Request** — **POST** `/api/v1/schedule/plan`

**cURL**

```bash
curl -X POST "https://reassign.app/api/v1/schedule/plan" \
  -H "Authorization: Bearer rsk_live_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "requests": [
    {
      "name": "Write the launch post",
      "durationMinutes": 90,
      "earliest": "2026-08-14T09:00",
      "latest": "2026-08-14T18:00",
      "areaId": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
      "projectId": "f3a4b5c6-d7e8-4f90-a1b2-c3d4e5f60718",
      "priority": 3,
      "requestId": "launch-post-0814"
    }
  ]
}'
```

**JavaScript**

```js
const res = await fetch("https://reassign.app/api/v1/schedule/plan", {
  method: "POST",
  headers: {
    Authorization: "Bearer rsk_live_YOUR_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "requests": [
      {
        "name": "Write the launch post",
        "durationMinutes": 90,
        "earliest": "2026-08-14T09:00",
        "latest": "2026-08-14T18:00",
        "areaId": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
        "projectId": "f3a4b5c6-d7e8-4f90-a1b2-c3d4e5f60718",
        "priority": 3,
        "requestId": "launch-post-0814"
      }
    ]
  }),
});
const data = await res.json();
```

**Response**

```json
{
  "timezone": "Europe/Ljubljana",
  "undoToken": "00000000-0000-4000-8000-000000000001",
  "expiresAt": "2026-08-13T09:12:00.000Z",
  "results": [
    {
      "index": 0,
      "status": "ok",
      "result": {
        "event": {
          "id": "b7f1c2a0-1d2e-4f3a-9b8c-5d6e7f801234",
          "start": "2026-08-14T11:00",
          "end": "2026-08-14T12:30",
          "kind": "blocking",
          "name": "Write the launch post",
          "source": "reassign",
          "areaId": "3c9d2a41-6b7e-4f80-9a1b-2c3d4e5f6071",
          "activityTypeId": null,
          "projectId": "f3a4b5c6-d7e8-4f90-a1b2-c3d4e5f60718",
          "priority": 3
        }
      }
    }
  ]
}
```

---

## Undo a change

**POST** `/api/v1/actions/undo`

Reverse one or more recent writes. Send 1 to 20 `tokens`. Each token is the `undoToken` of a write that recorded an undo receipt. Scope: `events:write`.

- A token is valid until its `expiresAt`, 30 minutes after the write.
- You can use a token one time only.
- The `results` rows are in the order of `tokens`. The server undoes the newest write first. Thus two tokens that change one row work in any order.
- The undo stops when a row that the write changed has a newer change. Refer to [Stale tokens](https://reassign.dev/reference/scheduling.md#stale-tokens).
- The response has only `results`. An undo does not return a new `undoToken`. You cannot undo an undo.

These routes can return an `undoToken` when the server records an undo receipt: `POST /events`, `POST /schedule/plan`, `POST /reviews/{date}`, `POST /backlog`, `POST /categories`, and `POST /command`. The tracker and moment routes return no `undoToken`. Refer to [Moments & trackers](https://reassign.dev/reference/moments-and-trackers.md).

### Row results

Each `ok` row has one shape: `{ reverted, restored, removed, voided, occurrenceRestored? }`. The counts count the rows that the undo changed.

| Field                | Value                                                                                                                           |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `reverted`           | The total: `restored` + `removed` + `voided`                                                                                    |
| `restored`           | The rows that the undo put back                                                                                                 |
| `removed`            | The rows that the undo deleted or marked as deleted                                                                             |
| `voided`             | The open proposals that the undo cancelled; current plan calls create none, so their undo returns 0                             |
| `occurrenceRestored` | The occurrences of a series that the undo put back. It is a part of `restored`. The field is there only when it is more than 0. |

The four counts `reverted`, `restored`, `removed`, and `voided` are always there. A count is 0 when nothing of that kind changed. A calendar link or a task link is not a counted row.

| Write that you undo                                                          | Counts that can be more than 0 |
| ---------------------------------------------------------------------------- | ------------------------------ |
| A plan                                                                       | `removed` (the booked events)  |
| An event write, a day review, a command, a category write, or an Inbox write | `restored` and `removed`       |

The undo of an Inbox capture to a task list cancels the task create when the server did not send it yet. When the task exists, the undo deletes it in its app. Linear cannot delete an issue, so the undo closes it.

### Stale tokens

The server stores a stamp of each row that a write changed. The undo compares each row with its stamp. A sync that changes only the bookkeeping fields of a row does not change the stamp. The stamp of an event or an Inbox item includes its project and its priority. A token from before projects stays valid while the row has no project and the priority 0.

The undo fails the token row with `stale` in these cases:

- A row that the write changed has a newer change.
- A row is gone, or a row that the write did not delete is now deleted.
- The token is from before the stamps.
- A category undo would delete a category that the write made, and an event, an Inbox item, a tracker, or a calendar default now uses it.
- A user changed the area, the activity type, the project, or the priority of an event or an Inbox item by hand. An AI classification after the write does not make the token stale.
- A category undo would show a shared default again, and a category of the same type now has its name.

A `stale` row changes nothing. A token that an undo already used is `validation`, not `stale`.

A `stale` row from a stamp check has `error.conflictingRows`: up to 50 items `{ entity, id, reason }`. `error.conflictingRowsTruncated: true` shows that more rows block the undo. A token from before the stamps, and a category refusal, have no `conflictingRows`.

| Field    | Value                                                                                                                                                                                                                                                                                                     |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `entity` | The stored type of the row: `sections` (events), `backlog_blocks` (Inbox items), `source_links` (calendar and task links), `day_reviews`, `moment_entries` (moments), `moments` (trackers), `source_calendars` (calendars), `source_projects` (Linear projects), `areas`, `activity_types`, or `projects` |
| `id`     | The id of the row                                                                                                                                                                                                                                                                                         |
| `reason` | `changed`: the row changed after the write. `deleted`: the row is gone. `newly_present`: a row that the write removed is there again.                                                                                                                                                                     |

### Item errors

| Cause                                                                                                             | `error.message`                                                                                                                                                             | `error.code` |
| ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| The token is not a UUID                                                                                           | `Unknown token "<token>".`                                                                                                                                                  | `validation` |
| An unknown token                                                                                                  | `Unknown token "<token>".`                                                                                                                                                  | `not_found`  |
| A token that you already used                                                                                     | `Nothing to undo for this token (status: <status>).`                                                                                                                        | `validation` |
| The token expired                                                                                                 | `The 30-minute undo window has passed; the change stays.`                                                                                                                   | `validation` |
| A calendar sync sends the change now                                                                              | `A calendar sync is sending this change now. Try the undo again in a moment.`                                                                                               | `conflict`   |
| A row has a newer change. The row also has `conflictingRows`.                                                     | `A row that this write changed has a newer change. The undo stops, so that it does not overwrite it. Nothing changed.`                                                      | `stale`      |
| The token has no stamps                                                                                           | `This undo token is from an older version and cannot be checked. Undo is not available for it.`                                                                             | `stale`      |
| A category that the write created now has a link                                                                  | `An event, an Inbox item, a tracker or a calendar now uses a category that this write created. The undo stops, so that it does not clear that link. Nothing changed.`       | `stale`      |
| A category of the same type now has the name of a hidden shared default                                           | `A category named "<name>" now exists. The undo stops, so that the list does not show two of them. Nothing changed.`                                                        | `stale`      |
| A project that the write created now has a link                                                                   | `An event, an Inbox item, a calendar or a Linear project now uses a project that this write created. The undo stops, so that it does not clear that link. Nothing changed.` | `stale`      |
| The undo gives a project the name of a different live project                                                     | `A project named <name> now exists in <area>. The undo stops, so that the list does not show two of them. Nothing changed.`                                                 | `stale`      |
| The Linear project of a `relink` now feeds a different project, or the old project is gone or archived            | `The Linear project that this write linked now feeds another project, or the old project is gone or archived. The undo stops. Nothing changed.`                             | `stale`      |
| The list or the Linear project of a `split` now feeds a different project, or the old project is gone or archived | `The list or Linear project that this write split now feeds another project, or the old project is gone or archived. The undo stops. Nothing changed.`                      | `stale`      |
| The token has a damaged category entry                                                                            | `This undo token has a malformed taxonomy relink entry. Undo is not available for it.`                                                                                      | `validation` |

A sync race changes nothing. Send the undo again after a short time. Do not send a `stale` token again: it fails again. When all the rows of the call fail with `conflict`, or all fail with `stale`, the status is `409`. A mix of codes gives `400` with `batch_rejected`.

### Request body

- `tokens` (_string\[], required_): The `undoToken` from an earlier write’s successfully recorded undo receipt.

### Response fields

- `results` (_object\[], required_)
  - One form: `status: "ok"`
    - `index` (_integer, required_)
    - `result` (_object, required_)
      - `reverted` (_integer, required_)
      - `restored` (_integer, required_)
      - `removed` (_integer, required_)
      - `voided` (_integer, required_)
      - `occurrenceRestored` (_integer_)
    - `warnings` (_"classification\_pending"\[]_)
  - One form: `status: "error"`
    - `index` (_integer, required_)
    - `error` (_object, required_)
      - `code` (_"conflict" | "not\_found" | "validation" | "permission" | "scope" | "stale" | "read\_only" | "rate\_limited" | "internal", required_)
      - `message` (_string, required_)
      - `issues` (_object\[]_)
        - `path` (_string | integer\[], required_)
        - `message` (_string, required_)
        - `expected` (_string_)
      - `feature` (_string_)
      - `conflictingRows` (_object\[]_)
        - `entity` (_string, required_)
        - `id` (_string, required_)
        - `reason` (_"changed" | "deleted" | "newly\_present", required_)
      - `conflictingRowsTruncated` (_true_)
      - `usage` (_object_)
        - `events` (_integer, required_)
        - `inboxItems` (_integer, required_)
        - `trackers` (_integer, required_)
        - `calendars` (_integer, required_)
        - `projects` (_integer_)
      - `reassignTargets` (_object\[]_)
        - `id` (_string, required_)
        - `name` (_string, required_)
      - `unlinkedCheckout` (_object_)
        - `sessionId` (_string, required_)
        - `paymentReceived` (_boolean, required_)
        - `supportUrl` (_string, required_)
      - `conflicts` (_object\[]_)
        - One form: With `id`
          - `start` (_string, required_)
          - `end` (_string, required_)
          - `id` (_string, required_)
          - `name` (_string, required_)
        - One form: With `batchIndex`
          - `start` (_string, required_)
          - `end` (_string, required_)
          - `batchIndex` (_integer, required_)
          - `name` (_string, required_)
      - `nearestSlots` (_object\[]_)
        - `start` (_string, required_)
        - `end` (_string, required_)
        - `score` (_number_)
        - `reason` (_string_)
  - One form: `status: "skipped"`
    - `index` (_integer, required_)
    - `reason` (_string, required_)

### Responses

| Status | Codes                 | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------ | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`  | —                     | Success.                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `400`  | `batch_rejected`      | Every op failed, and the failed ops have different codes. The code is `batch_rejected`.                                                                                                                                                                                                                                                                                                                                                                                                |
| `401`  | `unauthorized`        | No Bearer token and no session cookie, or the token is invalid, expired, or revoked. The response carries a `WWW-Authenticate` challenge that points at the protected-resource metadata.                                                                                                                                                                                                                                                                                               |
| `403`  | `permission`, `scope` | A token without `events:write` gets `scope` "This token is not authorized to modify events (missing events:write scope)." An account without Reassign Pro access gets `permission` "Reassign needs an active subscription." The 7-day free trial includes Reassign Pro. A cookie session from another site gets `permission` "Cross-origin request."                                                                                                                                   |
| `404`  | `not_found`           | An op names an item that does not exist.                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `409`  | `conflict`            | The request conflicts with the current state: `conflict` "A calendar sync is sending this change now. Try the undo again in a moment." or `stale` "A row that this write changed has a newer change. The undo stops, so that it does not overwrite it. Nothing changed." or `stale` "This undo token is from an older version and cannot be checked. Undo is not available for it."                                                                                                    |
| `422`  | `validation`          | The request body is not valid. The message names the first bad field by its path. A repeated or unknown query parameter is also a 422.                                                                                                                                                                                                                                                                                                                                                 |
| `429`  | `rate_limited`        | Too many requests. The edge firewall limits each IP to 2000 requests per minute across `/api/v1`; that 429 comes from the edge and can have a body that is not this JSON and no `Retry-After` header. When an edge 429 has no `Retry-After`, retry after 60 seconds. The app also limits each Bearer credential to 600 requests per minute, and some routes limit each user. These limits return this body. This app 429 always has `Retry-After`: retry after that number of seconds. |
| `503`  | `internal`            | A fault on our side. The request is safe to retry.                                                                                                                                                                                                                                                                                                                                                                                                                                     |

Most error responses: When no op of the batch applied, the body also has the `results` rows, so the caller sees which op failed.

**Request** — **POST** `/api/v1/actions/undo`

**cURL**

```bash
curl -X POST "https://reassign.app/api/v1/actions/undo" \
  -H "Authorization: Bearer rsk_live_YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "tokens": [
    "00000000-0000-4000-8000-000000000001"
  ]
}'
```

**JavaScript**

```js
const res = await fetch("https://reassign.app/api/v1/actions/undo", {
  method: "POST",
  headers: {
    Authorization: "Bearer rsk_live_YOUR_TOKEN",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "tokens": [
      "00000000-0000-4000-8000-000000000001"
    ]
  }),
});
const data = await res.json();
```

**Response**

```json
{
  "results": [
    {
      "index": 0,
      "status": "ok",
      "result": {
        "reverted": 1,
        "restored": 1,
        "removed": 0,
        "voided": 0
      }
    }
  ]
}
```
