Home›Leavora›API
Leavora · API

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.

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 & pathWhat it does
GET /api/pingVerify a key
GET /api/requestsNewest 50. ?employee_id=, ?leave_type_id=, ?status=, ?from=, ?to=
POST /api/requestsBook leave
GET /api/requests/{id}One request
POST /api/requests/{id}/approvePending → approved
POST /api/requests/{id}/rejectPending → rejected
GET /api/employeesRoster. ?balances=1, ?as_of=YYYY-MM-DD
GET /api/employees/{id}One employee with balances
GET /api/leave-typesLeave types and their accrual policy
GET /api/calendar?month=YYYY-MMWho is off, plus weekends and holidays
GET /api/openapi.jsonOpenAPI 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
ToolWrites?What it does
list_requestsnoRequests, filterable by status, employee and date window.
request_detailnoOne request, plus can_approve and the exact refusal if it cannot be.
submit_requestYESCreates a pending request under the web form's own rules.
decideYESApprove or reject a pending request. Admin keys only.
team_calendarnoWho is off in a window — approved leave only.
balances_reportnoBalances 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 addresshttps://your-install/mcp
The keyheader Authorization: Bearer apk_xxxx
The transportMCP 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"
}

**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"*. 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:

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.
EventFires when
leave.requestedA request entered the approver's queue
leave.approvedAn approver accepted it
leave.rejectedAn approver sent it back
leave.off_todayThe daily list of who is off, when switched on
leave.changedThe 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.

← Back to Leavora · Manual · Quickstart · Test run