# Rostera REST API & Webhooks (v2.0)

Rostera ships a JSON REST API and signed webhooks so the rota can talk to whatever else you
run — a time clock, a payroll export, a Slack channel, a door system, or your own scripts.

## Authentication

Create a key in **API & Webhooks** (in the sidebar). A key belongs to a user and can do what
that user can do in the app. Send it on every call:

    Authorization: Bearer apk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time; a revoked key
stops working immediately.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot add a shift (`403 forbidden`).
A staff login cannot use the API: a key held by one is refused (`403 forbidden`), because the API reads and writes the whole team's records. Staff use My rota.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/rota?week=YYYY-MM-DD` | One week of the rota. Any date inside the week works — it is snapped to your configured week start. `?status=draft\|published` filters. |
| `POST /api/shifts` | Schedule a shift |
| `GET  /api/employees` | The roster. `?active=0` includes archived staff. |
| `GET  /api/hours?from=&to=` | Per-employee hours for a date range |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |

### Schedule a shift

    curl -X POST https://your-install/api/shifts \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"employee_id": 3, "date": "2026-08-10", "start": "09:00", "end": "17:00",
           "label": "Front of house", "status": "draft"}'

Omit `employee_id` (or send `0`) to create an **open / unassigned** shift.

**The API runs the same guards as the rota screen.** This is the part worth knowing:

- Times go through the app's own parser, so `9:00`, `09:00` and `0900` all work, and a
  zero-length shift is refused with the same sentence the form uses.
- A shift whose end time is at or before its start is understood as **crossing midnight**, not
  as an error. The response says so explicitly in `crosses_midnight`, so a consumer comparing
  `"22:00"` to `"06:00"` cannot read the shift as negative.
- `duration_minutes` is the **real, DST-aware** length, computed in your business timezone. A
  shift across a clock change is 7 or 9 hours, not always 8.
- **Double-booking is refused with `409`**, using the identical overlap check the rota screen
  runs — including the day either side, so a shift that runs past midnight still collides. The
  detail line names the conflicting shift's id.
- **Approved time off is refused with `409` too** (3.2): a person on approved time off cannot be put
  on a shift that day, with a detail line naming their dates.

There is no second, weaker validation path: the API and the form call the same function.

### Hours

`GET /api/hours` returns exactly what the **Hours report** page shows, from the same
aggregation code — per employee, with `minutes`, `published_minutes` and the weekly target.
Open/unassigned shifts come back as a row with `employee_id: null`. Payroll pulled through the
API therefore cannot disagree with the figure your manager is looking at on screen.

## Webhooks

Add receiver URLs in **API & Webhooks**. Events:

- `shift.created` — any shift is scheduled: from the form, the API, the AI agent, **Copy last week**, an Alt-drag duplicate on the rota, or a CSV import (one event per shift)
- `shift.swapped` — a manager approves a swap and an existing shift changes hands. The body names who gave the shift to whom: `swap_id`, `shift` (as it stands after the swap), `from_employee` and `to_employee` (each `id` and `name`) and `decided_by`. A swap schedules nothing new, so it never sends `shift.created`; before 3.2.0 it did. An endpoint that lists its events receives this one only once you tick it in **API & Webhooks**.
- `shift.claimed` — a manager approves a person's request to take an OPEN shift (3.2, from **My rota**). The body carries `claim_id`, `shift` (now assigned), `employee` (`id` and `name`) and `decided_by`.
- `rota.published` — a week's drafts go live (`week_start`, `week_end`, `published` count)

Each delivery is an HTTP POST with a JSON body and:

    X-Rostera-Event: shift.created
    X-Rostera-Signature: sha256=<hex HMAC-SHA256 of the raw body, keyed with the endpoint's secret>

Verify the signature before trusting a payload. Delivery is one attempt with a short timeout —
deliberately simple; the **Recent deliveries** log on the settings page shows every attempt and
its response code. Design receivers to be idempotent.

## CSV import

Two entities, both under **Import**:

- **Employees** — `name` required; `role` and `weekly target` (in hours, e.g. `37.5`) optional.
- **Shifts** — `employee`, `date`, `start`, `end` required; `label` and `status` optional. The
  employee must already exist and be active, so import staff first.

Both run a **mandatory dry-run** whose report is produced by the same code that performs the
import — the preview count is the count you get. Shift rows go through the double-booking guard
too, so an import cannot introduce an overlap the app itself would refuse. A row that cannot be
applied in full writes nothing at all.

## Limits, honestly

- The API acts at user level; there is no separate scope system beyond the app's own roles.
- One delivery attempt per webhook event (log + idempotent receivers, not a retry queue).
- `GET /api/rota` returns one week per call by design — it is a rota, not a data warehouse.
- Shifts can be created through the API but not edited or deleted; those stay in the UI, where
  the reassignment and clash-resolution flows live.

## MCP — the agent endpoint (new in 3.0)

Rostera speaks **MCP (Model Context Protocol)** at `POST /mcp`, authenticated with the same
revocable API keys the REST API uses.

| Tool | Writes? | What it does |
|---|---|---|
| `list_shifts` | no | Shifts in a week or date range, optionally for one person. |
| `shift_detail` | no | One shift, with its derived duration and midnight flag. |
| `assign_shift` | **YES** | Creates a shift or assigns an existing one. |
| `open_shifts` | no | Shifts nobody is assigned to yet. |
| `roster_period` | no | Hours per employee — the Hours report's own aggregation. |

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://your-install/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http rostera https://your-install/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "rostera",
  "server_url": "https://your-install/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://your-install/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Rostera implements the protocol, not an integration with a particular vendor.

### The double-booking guard applies to agents too

`assign_shift` runs the **same** validator and the **same** overlap check as the rota screen —
including the day either side, so a shift that runs past midnight still collides. A conflict is
refused with the screen's own sentence and the id of the shift it clashes with, and **nothing is
written**. There is exactly one call to that guard in the product; every door goes through it.

### Shift swaps (new in 3.0)

A worker offers a shift, a colleague accepts it, and a manager decides **once**. The approval is
the interesting part: it **re-runs the double-booking check for the accepting worker at the moment
of approval**, because their rota may have changed since they accepted. If it now clashes, the
swap is refused and nothing moves. A swap cannot be used to smuggle a conflict past the rule the
product exists to enforce.

### iCal feeds

Per-worker and full-roster, token-guarded and read-only. A worker's feed shows *their* shifts —
a personal token never serves the whole company's roster.


## Connect it to Zapier, Make or n8n

Rostera talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Rostera, open **API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://rostera.yourcompany.com`.

### When something happens in Rostera: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Rostera under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Rostera, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `shift.created` | A shift is scheduled, from any door (form, API, AI agent, Copy last week, a duplicate on the rota, or a CSV import) |
| `shift.swapped` | A manager approves a swap and a shift changes hands; the payload names who gave it to whom |
| `shift.claimed` | A manager approves a person's request to take an open shift |
| `rota.published` | A week's draft shifts go live; the payload carries the week and the count |

Every delivery carries `X-Rostera-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Rostera

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/shifts`: Schedule a shift

### Example recipes

- **shift.created → a row in Google Sheets:** webhook trigger on `shift.created`, then the Google Sheets "Create row" action with the fields you want.
- **shift.swapped → a message in Slack or Teams:** webhook trigger on `shift.swapped`, then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Rostera:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/shifts` (Schedule a shift).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Rostera's own automated tests check its API.
