Ownware
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.

/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

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"
}

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.

← Back to Leavora · Manual · Quickstart · Test run

Affiliate program
Recommend tools people own — earn 35% on every sale. 90-day tracking, instant delivery, payouts by Lemon Squeezy.
Become an affiliate →