Leavora API, as shipped in the download
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.
/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 queueleave.approved— an approver accepted itleave.rejected— an approver sent it back
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:
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):
{
"type": "mcp",
"server_label": "leavora",
"server_url": "https://your-install/mcp",
"authorization": "apk_xxxx",
"require_approval": "never"
}
Own Your AI reads a list of servers in this shape:
{
"mcpServers": [
{ "id": "leavora", "name": "Leavora", "url": "https://your-install/mcp",
"token": "apk_xxxx", "enabled": true }
]
}
**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 (read 6 September 2026) puts servers in .vscode/mcp.json under a "servers" object — *"an object that maps server names to their configurations"* — not an mcpServers array. Pasted as-is, the block above 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:
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.