# Leavora REST API & Webhooks (v2.0)

Leavora ships a JSON REST API and signed webhooks so leave can be booked and decided from
wherever your team already works — a Slack shortcut, an HR portal, an onboarding script — and so
the team calendar can be drawn by something other than this browser.

Leave length is **integer centidays** — hundredths of a day. Half-days are exactly why a float
would be the wrong choice: `0.5 + 0.5 + 0.5` is a bug waiting to happen, `50 + 50 + 50` is not.

## Authentication

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

    Authorization: Bearer lvk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

Every write checks the key holder's role, exactly as the screen does: a viewer's key cannot file leave, and only an administrator's key can approve or decline it (`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 their own leave page.

`/api/*` is CSRF-exempt by design — the key is the credential, and browsers never send it
implicitly.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/requests` | Newest 50. `?employee_id=`, `?leave_type_id=`, `?status=`, `?from=`, `?to=` |
| `POST /api/requests` | Book leave |
| `GET  /api/requests/{id}` | One request |
| `POST /api/requests/{id}/approve` | Pending → approved |
| `POST /api/requests/{id}/reject` | Pending → rejected |
| `GET  /api/employees` | Roster. `?balances=1`, `?as_of=YYYY-MM-DD` |
| `GET  /api/employees/{id}` | One employee **with** balances |
| `GET  /api/leave-types` | Leave types and their accrual policy |
| `GET  /api/calendar?month=YYYY-MM` | Who is off, plus weekends and holidays |
| `GET  /api/openapi.json` | OpenAPI 3 spec (no auth) |
| `GET  /healthz` | `{"ok":true,"app":"leavora","version":"2.0.0"}` (no auth) |

### Book leave

    curl -X POST https://your-install/api/requests \
      -H "Authorization: Bearer lvk_..." -H "Content-Type: application/json" \
      -d '{"employee_id": 3, "leave_type_id": 1,
           "start_date": "2026-03-02", "end_date": "2026-03-06", "reason": "Trip"}'

Omit `end_date` and it defaults to `start_date` — a one-day booking is the common case. Set
`"half_day": true` on a single-day request for a half day.

Validation is **not** re-implemented for the API. The request goes through
`_request_from_input()` — the exact function the browser form calls — so all four rules apply:

- the employee and leave type must exist;
- the dates must be real and in order;
- the span must contain **at least one working day**; and
- it must not overlap an existing **pending or approved** request for that person.

A refusal returns **422** with the sentence the form would have shown:

    {"error": "unprocessable",
     "detail": "That span has no working days (all weekend/holiday). Pick a range that includes
                a working day."}

### Leave is counted in working days

Weekends and public holidays are not leave. Mon–Fri with a Wednesday holiday is **4 days**, not 5;
a Saturday-to-Sunday booking is refused outright because it consumes nothing. Which days count as
the weekend is configurable (Settings), and `/api/calendar` returns that same configuration — so a
client drawing a chart cannot shade a different set of days than Leavora counted.

    {"days_centidays": 400, "days": "4", "days_label": "4 days", "half_day": false}

### Two overlap guards, and they are not the same rule

- **Booking** refuses a clash with any **pending or approved** request. You should not be able to
  double-book yourself while a request is still in the queue.
- **Approving** refuses a clash with an already-**approved** request only. Two requests may
  legitimately sit pending over the same days — a change of plan submitted before the first was
  decided — but approving both would put someone on leave twice.

    {"error": "conflict",
     "detail": "Cannot approve — it overlaps an already-approved request for this employee."}

**Rejection is never blocked.** The clash rule is about approvals; refusing to let an approver
say "no" to a request would be absurd. And a decided request cannot be decided again (**409**,
`"That request has already been decided."`).

### Balances

`?balances=1` (or `GET /api/employees/{id}`) adds each person's position per leave type, straight
from `Leave::balanceAsOf()` — the same accrual, carry-over and pending-deduction arithmetic the
employee page and the PDF statement use:

    "balances": [{"leave_type": "Annual", "year_label": 2026, "year_start": "2026-01-01",
                  "carry_in_centidays": 500, "accrued_centidays": 1250,
                  "entitlement_centidays": 1750, "taken_centidays": 400,
                  "pending_centidays": 300, "available_centidays": 1050,
                  "available": "10.5"}]

`available` already has **pending** deducted — it is what the person can still book, not what
they have theoretically earned. Pass `?as_of=YYYY-MM-DD` to value the balance at a date (accrual
is time-based, so "how much will they have in June?" is a real question).

## Webhooks

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

- `leave.requested` — a request entered the approver's queue
- `leave.approved` — an approver accepted it
- `leave.rejected` — an approver sent it back
- `leave.off_today` — the daily list of who is off, when switched on
- `leave.changed` — the dates (or type) of a pending or approved request changed: a manager edited them, or approved a change the person asked for. The body carries the request as it is now.

Every delivery is signed:

    X-Leavora-Event: leave.approved
    X-Leavora-Signature: sha256=<hmac_sha256(body, your endpoint secret)>

Verify by recomputing the HMAC over the raw body with the endpoint's secret. Deliveries are logged
(newest 200 per endpoint) with the response code. Delivery is best-effort with a short timeout: a
slow receiver must never block a booking.

## Email notifications

Bring your own SMTP (**Settings → Email notifications**). Leave the host blank and Leavora sends
nothing — no external service is contacted. Two toggles:

- **Leave requested** (on by default) — who, which type, which dates, how many days, and the reason
- **Request approved or rejected** (off by default — the approver already knows)

## CSV import (employees)

Bring a roster: `name` is required; `email` and `start_date` are picked up when present
("Employee Name", "Work Email", "Hire Date" and similar wording all work). A missing start date
defaults to today — accrual is measured *from* it, so a person with no start date would have no
meaningful balance. An employee whose name already exists is skipped, never duplicated. Nothing is
written until you confirm the dry-run report.

**There is no allowance column, deliberately.** In Leavora an allowance is a property of the
**leave type** — an accrual policy the team shares (Setup → *Leave types*) — not a number stored
per person. A column of per-employee allowances has nowhere to go, and accepting it would mean
silently discarding it.

## Documented limits

- **Requests are create-and-read plus the two decisions.** Editing and deleting stay in the UI: a
  decided request is an approver-stamped record, and changing its dates changes someone's balance.
- **Employees, leave types and holidays are read-only over the API.** Add people via the UI or the
  CSV import; a leave policy is not set by an integration.
- **No per-employee allowance**, per the note above — that is a leave-type property in this
  product.

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

Leavora speaks **MCP (Model Context Protocol)** on one route, so an assistant can read the leave
book, submit requests and — if its key belongs to an admin — decide them.

    POST /mcp
    Authorization: Bearer lvk_...          ← the SAME revocable key the REST API uses
    Content-Type: application/json

| Tool | Writes? | What it does |
|---|---|---|
| `list_requests` | no | Requests, filterable by status, employee and date window. |
| `request_detail` | no | One request, plus `can_approve` and the exact refusal if it cannot be. |
| `submit_request` | **YES** | Creates a pending request under the web form's own rules. |
| `decide` | **YES** | Approve or reject a **pending** request. Admin keys only. |
| `team_calendar` | no | Who is off in a window — approved leave only. |
| `balances_report` | no | Balances per employee and type from the accrual engine. |

### 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 leavora 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": "leavora",
  "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:
Leavora implements the protocol, not an integration with a particular vendor.

### Decide-once

A request can be decided **once**. A second `decide` on the same request is refused with the same
sentence the browser shows — *"That request has already been decided."* — because the guard lives
in one function that the web app, the REST API and this tool all call. There is no path in the
product that writes a decision without passing through it.

### Balances: what the tool does and does not do

`submit_request` does **not** block a request that exceeds someone's balance — **and neither does
the web form.** Leavora treats the approver as the control, not the submission form, which is why
advance and unpaid leave are possible at all. Inventing a stricter rule for agents would mean an
assistant refusing something a human could do in the UI two seconds later.

Instead the response carries `balance_after`, so the model can say *"this takes Ava to −3 days"*
before anyone approves it. Note that a balance **can** go negative in the current year; what never
goes negative is the amount carried into the next year.

### Refusals

Nested arguments, unknown arguments (refused by name), out-of-enum values, overlapping requests
(same sentence as the form), and role: a member key cannot see or call `decide`.

Every MCP write lands in the audit trail beside the browser and API writes.


## Connect it to Zapier, Make or n8n

Leavora 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 Leavora, 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://leavora.yourcompany.com`.

### When something happens in Leavora: 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 Leavora under **API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Leavora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `leave.requested` | A request entered the approver's queue |
| `leave.approved` | An approver accepted it |
| `leave.rejected` | An approver sent it back |
| `leave.off_today` | The daily list of who is off, when switched on |
| `leave.changed` | The dates (or type) of a pending or approved request changed: a manager edited them, or approved a change the person asked for. The body… |

Every delivery carries `X-Leavora-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 Leavora

- **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/requests`: Book leave
- `POST /api/requests/{id}/approve`: Pending → approved
- `POST /api/requests/{id}/reject`: Pending → rejected

### Example recipes

- **leave.requested → a row in Google Sheets:** webhook trigger on `leave.requested` (A request entered the approver's queue), then the Google Sheets "Create row" action with the fields you want.
- **leave.approved → a message in Slack or Teams:** webhook trigger on `leave.approved` (An approver accepted it), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Leavora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/requests` (Book leave).

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