Overview
LeaveCalc has a small JSON API and webhooks. Typical uses: send approved time off to a payroll or HR tool, add a new hire from a form, show who is out on your own page. If you do not write code, Zapier and Make can do all of this for you with their own Webhooks and HTTP steps. To use LeaveCalc from an AI assistant, see AI assistants (MCP): the same tokens work there. IT administrators who want people to sign in through the company identity provider: see SAML single sign-on.
- Read: people, leave types, balances in hours and days, time off requests, who is out, holidays, and the activity log.
- Change: add and change people, and ask for time off. A request made through the API is pending and goes through your normal approval, like any other.
- Listen: webhooks send a short signed message when a request is sent or decided, when someone is added or changed, when a license expires, and more.
leave_type time_off and policy_name "Time off", and request notes are not sent. A token or webhook made with Include the type of sick and medical leave sees the real type, and the notes of other requests. The note of a sick leave, FMLA or protected request is never sent, whatever the switch says.Quick start
- In LeaveCalc open Settings, Integrations, API tokens and choose Create token. Start with a token that can only read. Copy it: it is shown once.
- Try it. Put the token in a variable and ask who you are:
export LEAVECALC_TOKEN="lc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" curl -s "https://leavecalc.com/api/v1/ext/me" \ -H "Authorization: Bearer $LEAVECALC_TOKEN" - To be told when something happens, open Settings, Integrations, Webhooks, Add webhook, paste the address of your server, and choose what to send. Then read Check the signature.
Authentication
Send your token in the Authorization header:
Authorization: Bearer lc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx- A token looks like
lc_followed by 43 characters. It is shown once when you make it. We keep only a fingerprint of it, so we cannot show it again: if you lose it, remove it and make a new one. - There are two kinds. A token that can only read may call every GET. A token that can read and change may also POST, PATCH and DELETE.
- Tokens work on
/api/v1/ext/only. A token never opens the app, and signing in to the app does not open the API. No cookie or CSRF header is needed. - Only owners make and remove tokens. Make one token for each tool, so you can remove one without breaking the others. Removing a token stops it at once, and so does removing the person who made it or taking away their owner role.
- A token is made without the switch Include the type of sick and medical leave unless you turn it on (a payroll connection may need it). Without it, sick, FMLA and protected leave read "Time off" and request notes are not sent.
- Every change made through a token is written to your audit log with the token name as the author.
No token
curl -s "https://leavecalc.com/api/v1/ext/me"HTTP 401 Unauthorized
{
"error": "missing_token",
"message": "Send your token as \"Authorization: Bearer <token>\"."
}A token that is wrong or removed
curl -s "https://leavecalc.com/api/v1/ext/me" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 401 Unauthorized
{
"error": "invalid_token",
"message": "This token is not valid. It may have been removed."
}A read token tries to add a person
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Nope"
}'HTTP 403 Forbidden
{
"error": "insufficient_scope",
"message": "This token can only read. Make a token that can read and write to change things."
}Conventions
- JSON in and out, UTF-8. Send
Content-Type: application/jsonwith a body. - Dates are ISO:
2026-10-05. Moments are UTC:2026-10-05T14:03:11Z. "Today" is today in your company time zone. - Ids are whole numbers. Every person, request, leave type and webhook belongs to one company: asking for one of another company is a 404.
- Leave is kept in hours. Answers also give days, using the working day of each person (a person who works 6 hours a day has 6 hours in a day).
- Fields we do not know are refused (400
unknown_field) instead of ignored, so a typo does not silently do nothing. - Names of fields are stable inside v1. We may add new fields to an answer: ignore ones you do not know.
Rate limit
Each token may make 120 requests a minute, counted over any 60 seconds. Beyond that the answer is 429 with a Retry-After header (seconds). Every answer carries X-RateLimit-Limit and X-RateLimit-Remaining. The count is kept in memory by the server, so a restart starts it again. All the tokens of one company together may make 300 requests a minute. A token with a wrong value is throttled on its own.
Too many requests
curl -s "https://leavecalc.com/api/v1/ext/me" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 429 Too Many Requests
Retry-After: 61
{
"error": "rate_limited",
"message": "Too many requests. This token may make 120 a minute. Try again in 61 seconds.",
"retry_after": 61
}Errors
Every error has the same shape as the rest of LeaveCalc: {"error": "...", "message": "..."}. error is a short code to test for, message is a sentence you can show to a person. Some errors carry more, such as seat_limit.
| Status | error | When |
|---|---|---|
401 | missing_token | There is no Authorization header. |
401 | invalid_token | The token is wrong or was removed. |
403 | insufficient_scope | A token that can only read tried to change something. |
403 | not_api_webhook | You tried to remove a webhook that was made in the app. |
400 | bad_... | A value is not valid. The code names the field, for example bad_start_date, bad_email, bad_limit, bad_cursor, bad_url, bad_events. The message says what to fix. |
400 | missing_... | A required value is missing, for example missing_name or missing_start_date. |
400 | unknown_field | You sent a field that cannot be set through the API (pay, notes, approvers and so on). |
402 | seat_limit | Your plan has no room for another active person. The answer includes seat_limit. |
404 | not_found | There is no such person, request, leave type or webhook in your company. |
409 | email_exists | Someone with this email is already on the team. The answer includes employee_id. |
409 | blackout | The dates fall in a blackout period that blocks requests. |
409 | too_many_endpoints | You already have 10 webhooks. Remove one first. |
403 | owner_token_required | The activity log was asked for with a token that was not made by an owner. |
429 | rate_limited | More than 120 requests in a minute with this token. Wait for Retry-After seconds. |
Not found
curl -s "https://leavecalc.com/api/v1/ext/employees/99999" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 404 Not Found
{
"error": "not_found",
"message": "Person not found."
}A value is not valid
curl -s -X POST "https://leavecalc.com/api/v1/ext/requests" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"employee_id": 1,
"policy_id": 1,
"start_date": "2026-12-10",
"end_date": "2026-12-01"
}'HTTP 400 Bad Request
{
"error": "bad_range",
"message": "The end date is before the start date."
}A field that cannot be set
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Sneaky",
"pay_rate": 40
}'HTTP 400 Bad Request
{
"error": "unknown_field",
"message": "\"pay_rate\" cannot be set here. For a person you can send: name, email, start_date, department, location_id, location, hours_per_week, working_days, status."
}Someone with that email is already on the team
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Again",
"email": "priya.shah@example.com"
}'HTTP 409 Conflict
{
"error": "email_exists",
"message": "Someone with this email is already on your team.",
"employee_id": 3
}The plan has no room
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Three"
}'HTTP 402 Payment Required
{
"error": "seat_limit",
"message": "Your plan covers 2 active people. Upgrade to add more.",
"seat_limit": 2
}Pagination and polling
Lists answer {"items": [...], "next_cursor": "..."}. When next_cursor is not null, call again with cursor= that value, the same filters and the same order, until it is null. The cursor is opaque: do not build one yourself. limit is 50 by default and 200 at most.
First page, one person
curl -s "https://leavecalc.com/api/v1/ext/employees?limit=1" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2025-03-03",
"end_date": null,
"hours_per_week": 40.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
],
"next_cursor": "eyJvIjoiYXNjIiwiaSI6MX0"
}The next page, with the cursor
curl -s "https://leavecalc.com/api/v1/ext/employees?limit=1&cursor=eyJvIjoiYXNjIiwiaSI6MX0" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic",
"location_id": null,
"location": null,
"start_date": "2024-06-17",
"end_date": null,
"hours_per_week": 32.0,
"working_days": [
"mon",
"tue",
"wed",
"thu"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
],
"next_cursor": null
}Polling for changes. People and requests have an updated_at that moves with every change, and a list takes updated_since. Keep the largest updated_at you have seen and ask again with it. The filter includes that exact second, so you may see the same item again: de-duplicate by id. Webhooks are better than polling when you can receive them.
Only people changed since a moment
curl -s "https://leavecalc.com/api/v1/ext/employees?updated_since=2026-10-01T00:00:00Z&limit=1" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2025-03-03",
"end_date": null,
"hours_per_week": 40.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
],
"next_cursor": "eyJvIjoiYXNjIiwiaSI6MX0"
}Endpoints
All paths are under https://leavecalc.com/api/v1. "Read token" means any token works; "Write token" needs the read and change kind.
GET /ext/me Read token
Your company and token
Company name, country, time zone, units and plan, and which token you are using. A good first call to check that a token works.
Your company and token
curl -s "https://leavecalc.com/api/v1/ext/me" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"company": {
"id": 1,
"name": "Maple Dental",
"country": "US",
"region": "OR",
"timezone": "America/Los_Angeles",
"leave_unit": "hours",
"hours_per_day": 8.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"date_format": "mdy",
"week_start": 1
},
"plan": {
"tier": "pro",
"seat_limit": 25,
"seats_used": 2,
"trial": true,
"expires": "2026-10-19"
},
"token": {
"name": "Payroll sync",
"scope": "write",
"prefix": "lc_xxxxx"
},
"api": {
"version": "v1"
}
}GET /ext/employees Read token
List people
Everybody on the team, in pages. Archived people are included unless you filter.
| Name | Where | Type | What it is |
|---|---|---|---|
status | query | text | active, inactive or terminated. Leave it out to get everyone. |
department | query | text | Exact department name, any capitals. |
email | query | text | Exact email address, any capitals. Handy to find one person. |
updated_since | query | date or moment | Only people changed at or after this moment, for example 2026-10-01T00:00:00Z. Calendar and sign-in bookkeeping does not count as a change. |
limit | query | whole number | How many items to return. Default 50, at most 200 (a larger number is treated as 200). |
cursor | query | text | The next_cursor of the previous answer. Use it with the same filters and the same order. |
order | query | asc or desc | asc (default) is oldest first by id, desc is newest first. |
The answer is {items, next_cursor}. next_cursor is null on the last page.
Two active people
curl -s "https://leavecalc.com/api/v1/ext/employees?status=active&limit=2" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2025-03-03",
"end_date": null,
"hours_per_week": 40.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
},
{
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic",
"location_id": null,
"location": null,
"start_date": "2024-06-17",
"end_date": null,
"hours_per_week": 32.0,
"working_days": [
"mon",
"tue",
"wed",
"thu"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
],
"next_cursor": null
}Shortened: only the first 2 of the entries are shown.
Find one person by email
curl -s "https://leavecalc.com/api/v1/ext/employees?email=ana.silva@example.com" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2025-03-03",
"end_date": null,
"hours_per_week": 40.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
],
"next_cursor": null
}GET /ext/employees/{id} Read token
One person
One person by id. A person of another company is a 404.
| Name | Where | Type | What it is |
|---|---|---|---|
id | path | whole number | The id of the person. |
One person
curl -s "https://leavecalc.com/api/v1/ext/employees/1" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2025-03-03",
"end_date": null,
"hours_per_week": 40.0,
"working_days": [
"mon",
"tue",
"wed",
"thu",
"fri"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}POST /ext/employees Write token
Add a person
Adds a person the way the Add person button does: the same checks, the same starting balances, and the same plan limit.
| Name | Where | Type | What it is |
|---|---|---|---|
name | body | text | Required. Up to 120 characters. |
email | body | text | Optional. A person with this email already on the team is a 409 email_exists, so a retried automation does not add them twice. |
start_date | body | date | Optional. The first day at work, like 2026-10-19. A date in the future means leave starts building from that day. |
department | body | text | Optional. Up to 80 characters. |
location | body | text | Optional. The name of one of your locations, any capitals. Or use location_id. |
location_id | body | whole number | Optional. The id of one of your locations. |
hours_per_week | body | number | Optional. 0.5 to 168. Default 40. |
working_days | body | list | Optional. For example ["mon","tue","wed"]. Default: the days your company works. |
Anything else, such as pay or notes, is refused with 400 unknown_field. Pay, private notes and approvers cannot be set or read through the API.
When your plan has no room left the answer is 402 seat_limit, exactly as in the app.
Add a person
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Shah",
"email": "priya.shah@example.com",
"start_date": "2026-10-19",
"department": "Clinic",
"hours_per_week": 24,
"working_days": [
"mon",
"tue",
"wed"
]
}'HTTP 201 Created
{
"id": 3,
"name": "Priya Shah",
"email": "priya.shah@example.com",
"department": "Clinic",
"location_id": null,
"location": null,
"start_date": "2026-10-19",
"end_date": null,
"hours_per_week": 24.0,
"working_days": [
"mon",
"tue",
"wed"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}Someone with that email is already on the team
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Priya Again",
"email": "priya.shah@example.com"
}'HTTP 409 Conflict
{
"error": "email_exists",
"message": "Someone with this email is already on your team.",
"employee_id": 3
}The plan has no room for another person
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Three"
}'HTTP 402 Payment Required
{
"error": "seat_limit",
"message": "Your plan covers 2 active people. Upgrade to add more.",
"seat_limit": 2
}PATCH /ext/employees/{id} Write token
Change a person
Changes the fields you send and leaves the rest. Setting status to inactive or terminated archives the person and signs them out of the employee page.
| Name | Where | Type | What it is |
|---|---|---|---|
id | path | whole number | The id of the person. |
name, email, start_date, department, location, location_id, hours_per_week, working_days | body | as above | Any of them. |
status | body | text | active, inactive or terminated. Bringing someone back to active needs a free place on your plan. |
A new number of hours or new working days count from today on; leave already given keeps the old schedule.
Change a person
curl -s -X PATCH "https://leavecalc.com/api/v1/ext/employees/3" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"department": "Front desk",
"hours_per_week": 28
}'HTTP 200 OK
{
"id": 3,
"name": "Priya Shah",
"email": "priya.shah@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2026-10-19",
"end_date": null,
"hours_per_week": 28.0,
"working_days": [
"mon",
"tue",
"wed"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}GET /ext/policies Read token
Leave types
The leave types of the company (PTO, sick leave, unpaid and so on). Archived ones are left out.
unit says whether the amounts of a leave type are counted in hours or in each person's own days.
Leave types
curl -s "https://leavecalc.com/api/v1/ext/policies" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"name": "PTO",
"leave_type": "pto",
"paid": true,
"tracks_balance": true,
"accrual_method": "per_pay_period",
"unit": "hours",
"location_id": null
},
{
"id": 2,
"name": "Sick (OR)",
"leave_type": "sick",
"paid": true,
"tracks_balance": true,
"accrual_method": "per_hours_worked",
"unit": "hours",
"location_id": null
},
{
"id": 3,
"name": "Unpaid",
"leave_type": "unpaid",
"paid": false,
"tracks_balance": false,
"accrual_method": "none",
"unit": "hours",
"location_id": null
}
]
}Shortened: only the first 3 of the entries are shown.
GET /ext/balances Read token
Balances
What each person has left, per leave type, in hours and in days. Days use the working day of that person.
| Name | Where | Type | What it is |
|---|---|---|---|
employee_id | query | whole number | One person. Without it you get every active person. |
as_of | query | date | The day to work out the balance for. Default: today in the company time zone. |
limit | query | whole number | How many items to return. Default 50, at most 200 (a larger number is treated as 200). |
cursor | query | text | The next_cursor of the previous answer. Use it with the same filters and the same order. |
order | query | asc or desc | asc (default) is oldest first by id, desc is newest first. |
balance is null for a leave type that does not keep a balance (unpaid, for example). The page size counts people, not leave types.
Balances of one person
curl -s "https://leavecalc.com/api/v1/ext/balances?employee_id=1&as_of=2026-10-05" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"employee_id": 1,
"employee_name": "Ana Silva",
"status": "active",
"balances": [
{
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"tracks_balance": true,
"balance": {
"hours": 96.0,
"days": 12.0
},
"accrued": {
"hours": 0.0,
"days": 0.0
},
"used": {
"hours": 0.0,
"days": 0.0
}
},
{
"policy_id": 3,
"policy_name": "Unpaid",
"leave_type": "unpaid",
"tracks_balance": false,
"balance": null,
"accrued": {
"hours": 0.0,
"days": 0.0
},
"used": {
"hours": 0.0,
"days": 0.0
}
}
]
}
],
"next_cursor": null,
"as_of": "2026-10-05"
}Shortened: only the first 2 of the entries are shown.
GET /ext/requests Read token
List time off requests
Requests in pages, with the person and the leave type already in each one.
| Name | Where | Type | What it is |
|---|---|---|---|
status | query | text | pending, approved, denied or cancelled. |
from | query | date | Requests that end on or after this day. |
to | query | date | Requests that start on or before this day. |
employee_id | query | whole number | One person. |
updated_since | query | date or moment | Only requests changed at or after this moment: sent, decided, edited or cancelled. |
limit | query | whole number | How many items to return. Default 50, at most 200 (a larger number is treated as 200). |
cursor | query | text | The next_cursor of the previous answer. Use it with the same filters and the same order. |
order | query | asc or desc | asc (default) is oldest first by id, desc is newest first. |
A sick leave, FMLA or protected request reads leave_type "time_off" and policy_name "Time off" (and no policy_id) unless the token was made with "Include the type of sick and medical leave". Notes are only sent with that switch, and never for those requests: note and decision_note are null and note_withheld says so.
Approved requests in October
curl -s "https://leavecalc.com/api/v1/ext/requests?status=approved&from=2026-10-01&to=2026-10-31&order=desc" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"employee_id": 1,
"employee_name": "Ana Silva",
"employee_email": "ana.silva@example.com",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-10-12",
"end_date": "2026-10-14",
"hours": 24.0,
"days": 3.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 24.0,
"effective_days": 3.0
}
],
"next_cursor": null
}Shortened: only the first 1 of the entries are shown.
GET /ext/requests/{id} Read token
One request
One request by id.
| Name | Where | Type | What it is |
|---|---|---|---|
id | path | whole number | The id of the request. |
One request
curl -s "https://leavecalc.com/api/v1/ext/requests/1" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"id": 1,
"employee_id": 1,
"employee_name": "Ana Silva",
"employee_email": "ana.silva@example.com",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-10-12",
"end_date": "2026-10-14",
"hours": 24.0,
"days": 3.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 24.0,
"effective_days": 3.0
}POST /ext/requests Write token
Ask for time off
Creates a request for a person. It is always pending and goes through your normal approval, with the same checks, the same emails and the same webhooks as a request made in the app.
| Name | Where | Type | What it is |
|---|---|---|---|
employee_id | body | whole number | Who it is for. Or use employee_email. |
employee_email | body | text | The email of an active person. |
policy_id | body | whole number | The leave type. Or use policy. |
policy | body | text | The name of the leave type, any capitals. |
start_date | body | date | Required. |
end_date | body | date | Optional. Default: the start date. |
hours | body | number | Optional. Only for part of a day. |
note | body | text | Optional. Up to 1000 characters. |
A blocking blackout period refuses the request with 409 blackout. A warning blackout and other warnings come back in warnings.
You cannot approve a request through the API. Approval stays with the people you chose in the app.
Ask for time off
curl -s -X POST "https://leavecalc.com/api/v1/ext/requests" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"employee_email": "ana.silva@example.com",
"policy": "PTO",
"start_date": "2026-11-23",
"end_date": "2026-11-25",
"note": "Long weekend"
}'HTTP 201 Created
{
"id": 3,
"employee_id": 1,
"employee_name": "Ana Silva",
"employee_email": "ana.silva@example.com",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-23",
"end_date": "2026-11-25",
"hours": 24.0,
"days": 3.0,
"partial_day": false,
"status": "pending",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "api",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": null,
"makeup_total": 0.0,
"effective_hours": 24.0,
"effective_days": 3.0,
"warnings": []
}A value is not valid
curl -s -X POST "https://leavecalc.com/api/v1/ext/requests" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"employee_id": 1,
"policy_id": 1,
"start_date": "2026-12-10",
"end_date": "2026-12-01"
}'HTTP 400 Bad Request
{
"error": "bad_range",
"message": "The end date is before the start date."
}GET /ext/whos-out Read token
Who is out
Approved time off on one day or across a range (up to 93 days).
| Name | Where | Type | What it is |
|---|---|---|---|
date | query | date | One day. Default: today in the company time zone. |
from | query | date | Start of a range. Use from and to instead of date. |
to | query | date | End of a range. |
Who is out on 13 October
curl -s "https://leavecalc.com/api/v1/ext/whos-out?date=2026-10-13" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"from": "2026-10-13",
"to": "2026-10-13",
"items": [
{
"request_id": 1,
"employee_id": 1,
"employee_name": "Ana Silva",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-10-12",
"end_date": "2026-10-14",
"partial_day": false,
"hours": 24.0,
"effective_hours": 24.0
}
]
}GET /ext/holidays Read token
Company holidays
The company holidays, or those of one location.
| Name | Where | Type | What it is |
|---|---|---|---|
year | query | whole number | Only holidays in this year. |
location_id | query | whole number | The holidays of that location. Without it, the company-wide list. |
Holidays in 2026
curl -s "https://leavecalc.com/api/v1/ext/holidays?year=2026" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 1,
"date": "2026-11-26",
"name": "Thanksgiving Day",
"location_id": null
},
{
"id": 2,
"date": "2026-12-25",
"name": "Christmas Day",
"location_id": null
}
]
}Shortened: only the first 3 of the entries are shown.
GET /ext/audit Read token
Activity log
Every change made in LeaveCalc as a sentence with who did it and when, the same as Settings, Activity log. Oldest first by default.
| Name | Where | Type | What it is |
|---|---|---|---|
employee_id | query | whole number | Only entries about this person. |
kind | query | text | One kind of change: requests, balances, people, policies, toil, credentials, holidays, team, settings, billing, connections, reports or system. |
action | query | text | One or several action codes, separated by commas, for example request.approved,request.denied. |
q | query | text | Entries that mention this: a name, a leave type or a word. |
from | query | date | Entries on or after this day, in the time zone of your company. |
to | query | date | Entries on or before this day. |
hide_automatic | query | true or false | Leave out the entries LeaveCalc writes by itself, such as sign-ins and follow-up balance rows. |
limit | query | whole number | How many items to return. Default 50, at most 200 (a larger number is treated as 200). |
cursor | query | text | The next_cursor of the previous answer. Use it with the same filters and the same order. |
order | query | asc or desc | asc (default) is oldest first by id, desc is newest first. |
Only a token made by an owner may read it (otherwise 403 owner_token_required). Pay is never in the log you get here, and neither are attendance and FMLA entries. Sick, FMLA and protected leave reads "time off" and request notes are left out, unless the token was made with "Include the type of sick and medical leave".
Entries are kept for 7 years by default. An owner can choose 10 years or for ever in Settings.
The last two changes to people
curl -s "https://leavecalc.com/api/v1/ext/audit?kind=people&order=desc&limit=2" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"items": [
{
"id": 23,
"created_at": "2026-10-05T14:03:11Z",
"action": "employee.update",
"kind": "people",
"sentence": "Payroll sync (API token) changed the details of Priya Shah.",
"actor": {
"type": "api",
"name": "Payroll sync"
},
"employee_id": 3,
"employee_name": "Priya Shah",
"changes": [
{
"label": "Department",
"before": "Clinic",
"after": "Front desk"
},
{
"label": "Hours a week",
"before": "24 hours a week",
"after": "28 hours a week"
}
],
"automatic": false
},
{
"id": 22,
"created_at": "2026-10-05T14:03:11Z",
"action": "employee.create",
"kind": "people",
"sentence": "Payroll sync (API token) added Priya Shah to the team.",
"actor": {
"type": "api",
"name": "Payroll sync"
},
"employee_id": 3,
"employee_name": "Priya Shah",
"changes": [
{
"label": "Email",
"value": "priya.shah@example.com"
},
{
"label": "Department",
"value": "Clinic"
},
{
"label": "Start date",
"value": "Oct 19, 2026"
},
{
"label": "Hours a week",
"value": "24 hours a week"
},
{
"label": "Working days",
"value": "Mon, Tue, Wed"
}
],
"automatic": false
}
],
"next_cursor": "eyJvIjoiZGVzYyIsImkiOjIyfQ"
}POST /ext/webhooks Write token
Subscribe to events
Makes a webhook, so a tool such as Zapier or Make can subscribe by itself. The signing secret is in this answer only.
| Name | Where | Type | What it is |
|---|---|---|---|
url | body | text | Required. The address to send to. https only, on the public internet. |
events | body | list | Optional. A list of event names, or ["*"] for everything except attendance. Default: everything. A single event can also be sent as event. |
description | body | text | Optional. A name for you, up to 120 characters. |
A company can have up to 10 webhooks, whether made in the app or here.
Subscribe
curl -s -X POST "https://leavecalc.com/api/v1/ext/webhooks" \
-H "Authorization: Bearer $LEAVECALC_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.zapier.com/hooks/standard/10442/ab12cd/",
"events": [
"request.approved"
],
"description": "Google Sheet"
}'HTTP 201 Created
{
"id": 3,
"url": "https://hooks.zapier.com/hooks/standard/10442/ab12cd/",
"host": "hooks.zapier.com",
"events": [
"request.approved"
],
"description": "Google Sheet",
"active": true,
"state": "active",
"disabled_reason": null,
"created_via": "api",
"created_at": "2026-10-05T14:03:11Z",
"last_delivery_at": null,
"last_status": null,
"consecutive_failures": 0,
"secret_prefix": "whsec_xxxx",
"include_sensitive": false,
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}DELETE /ext/webhooks/{id} Write token
Unsubscribe
Removes a webhook that was made through the API. One made in the app has to be removed in the app (403 not_api_webhook).
| Name | Where | Type | What it is |
|---|---|---|---|
id | path | whole number | The id from the subscribe answer. |
Unsubscribe
curl -s -X DELETE "https://leavecalc.com/api/v1/ext/webhooks/3" \
-H "Authorization: Bearer $LEAVECALC_TOKEN"HTTP 200 OK
{
"ok": true
}Fields of each object
The fields of the objects above, in the order they are sent. A field is always present (it may be null).
Person
| Field | Type | Notes |
|---|---|---|
id | whole number | The person id. |
name | text | |
email | text or null | |
department | text or null | |
location_id | whole number or null | The location, if the person has one. |
location | text or null | Its name. |
start_date | date or null | The first day at work. |
end_date | date or null | The last day, when someone has left. |
hours_per_week | number | |
working_days | list | The days this person works, Monday first: mon, tue, wed, thu, fri, sat, sun. |
status | text | active, inactive or terminated. |
created_at | moment | UTC. |
updated_at | moment | UTC. Moves with every change of the person. |
Time off request
| Field | Type | Notes |
|---|---|---|
id | whole number | |
employee_id | whole number | |
employee_name | text | |
employee_email | text or null | |
department | text or null | |
policy_id | whole number | The leave type. |
policy_name | text | |
leave_type | text | pto, sick, fmla, unpaid, holiday, bereavement, jury, toil or other. Always time_off for sick, FMLA and protected leave unless the token or webhook may see the type. |
start_date | date | |
end_date | date | |
hours | number | Working hours counted for the request. |
days | number | The same in days of that person. |
partial_day | true or false | True when hours was given for part of a day. |
status | text | pending, approved, denied or cancelled. |
note | text or null | The note of the person. Null unless the token or webhook may see notes, and always null for sick leave, FMLA and protected leave. |
note_withheld | true or false | True when there is a note that we do not give out. |
decision_note | text or null | What the approver wrote. Null for sick leave and FMLA. |
source | text | manager, link, portal or api: where the request came from. |
created_at | moment | |
updated_at | moment | Moves with every change: decision, edit, cancel. |
decided_at | moment or null | When it was approved, denied or cancelled. |
makeup_total | number | Hours worked afterwards to make up for this time off (make-up time recorded in the app). 0 when there is none. |
effective_hours | number | What the request counts for: hours minus makeup_total. Balances, reports and exports use this number. |
effective_days | number | The same in days of that person. |
Leave type
| Field | Type | Notes |
|---|---|---|
id | whole number | |
name | text | |
leave_type | text | The kind: pto, sick, fmla, unpaid, holiday and so on. |
paid | true or false | |
tracks_balance | true or false | False for leave types such as unpaid. |
accrual_method | text | none, per_hours_worked, per_pay_period, lump_sum or toil. |
unit | text | hours or days. |
location_id | whole number or null | Set when the leave type only applies to one location. |
Balance of one person
| Field | Type | Notes |
|---|---|---|
employee_id | whole number | |
employee_name | text | |
status | text | The status of the person. |
balances | list | One entry per leave type that applies to the person: policy_id, policy_name, leave_type, tracks_balance and three pairs {hours, days}: balance, accrued, used. |
Someone who is out
| Field | Type | Notes |
|---|---|---|
request_id | whole number | |
employee_id | whole number | |
employee_name | text | |
department | text or null | |
policy_id | whole number | |
policy_name | text | |
leave_type | text | |
start_date | date | |
end_date | date | |
partial_day | true or false | |
hours | number | The hours of the request. |
effective_hours | number | What the request counts for after make-up time. The same as hours when nothing was made up. |
Activity log entry
| Field | Type | Notes |
|---|---|---|
id | whole number | Grows with time. Use it with next_cursor. |
created_at | moment | UTC. |
action | text | A short code such as request.approved. |
kind | text | The kind of change, for example requests or people. |
sentence | text | What happened, ready to show to a person. |
actor | object | Who did it: type is user, api (another tool, by the name of its token), person (an employee on their own page) or system (LeaveCalc itself), and name. |
employee_id | whole number or null | The person the entry is about, if it is about a person. |
employee_name | text or null | |
changes | list | What changed: label and before and after for an edit, or label and value for something new. Can be empty. |
automatic | true or false | True for entries LeaveCalc writes by itself. |
Holiday
| Field | Type | Notes |
|---|---|---|
id | whole number or null | |
date | date | |
name | text | |
location_id | whole number or null | Null for a company-wide holiday. |
Webhooks
A webhook makes LeaveCalc call an address of yours when something happens. You do not have to ask again and again. Zapier and Make use the same thing.
Set one up
- In the app: Settings, Integrations, Webhooks, Add webhook. Paste the address, choose what to send, and copy the signing secret (shown once). Choose Send test to see it arrive.
- Or by API, with a token that can read and change:
curl -s -X POST "https://leavecalc.com/api/v1/ext/webhooks" \ -H "Authorization: Bearer $LEAVECALC_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.zapier.com/hooks/standard/10442/ab12cd/", "events": [ "request.approved" ], "description": "Google Sheet" }'HTTP 201 Created { "id": 3, "url": "https://hooks.zapier.com/hooks/standard/10442/ab12cd/", "host": "hooks.zapier.com", "events": [ "request.approved" ], "description": "Google Sheet", "active": true, "state": "active", "disabled_reason": null, "created_via": "api", "created_at": "2026-10-05T14:03:11Z", "last_delivery_at": null, "last_status": null, "consecutive_failures": 0, "secret_prefix": "whsec_xxxx", "include_sensitive": false, "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" } - The address must start with
https://and be on the public internet. We refuse addresses that point to private networks, to this computer or to cloud metadata services, we check again on every send, and we do not follow redirects. - You can have up to 10 webhooks. Attendance steps are never part of "all events": name
attendance.stepif you want it. - A webhook has the same switch as a token: Include the type of sick and medical leave (off unless you turn it on). With it off the message says "Time off" for sick, FMLA and protected leave and leaves out request notes.
What we send
A POST with a JSON body, always this envelope:
| Field | Type | Notes |
|---|---|---|
id | text | A unique id for the event, like evt_5f3a... The same event sent to two webhooks has the same id. Use it to ignore duplicates. |
kind | text | The event name, see the list below. |
created_at | moment | When it happened, UTC. |
company_id | whole number | Your company. |
data | object | The objects of this event, in the shapes above. |
Headers:
| Header | Value |
|---|---|
Content-Type | application/json; charset=utf-8 |
User-Agent | LeaveCalc-Webhooks/1.0 |
X-LeaveCalc-Event | The same as kind. |
X-LeaveCalc-Delivery | The id of this delivery. A new delivery id is used each time we send again from the app. |
X-LeaveCalc-Signature | t=<unix seconds>,v1=<hex>. See the next part. |
The body is compact JSON. Below it is shown indented to be readable.
{
"id": "evt_5f3a91c07d2e4b8a6c1d4133",
"kind": "request.approved",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 2,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-02",
"end_date": "2026-11-06",
"hours": 32.0,
"days": 4.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 32.0,
"effective_days": 4.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}Check the signature
Anyone can post to your address, so check that the message came from LeaveCalc. We sign "<t>.<raw body>" with HMAC-SHA256, using your signing secret as the key, and send the result as v1 in X-LeaveCalc-Signature.
- Read
tandv1from the header. - Refuse the message if
tis more than 5 minutes from your clock (a replay). - Compute the HMAC over
t, a dot, and the raw bytes of the body, before you parse any JSON. Re-serialising the JSON changes the bytes. - Compare with
v1in constant time.
Python
import hashlib
import hmac
import time
def verify(secret, header, raw_body, tolerance=300, now=None):
"""True when the message really came from LeaveCalc.
secret: the whsec_ text you saw once when you made the webhook.
header: the X-LeaveCalc-Signature header, for example "t=1759675391,v1=ab12...".
raw_body: the bytes you received, exactly as they arrived (before any JSON parsing).
"""
try:
parts = dict(p.split("=", 1) for p in header.split(","))
sent_at, signature = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
now = time.time() if now is None else now
if abs(now - sent_at) > tolerance: # older (or newer) than 5 minutes: refuse, it may be a replay
return False
expected = hmac.new(secret.encode(), str(sent_at).encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Node
const crypto = require("crypto");
// secret: the whsec_ text you saw once. header: X-LeaveCalc-Signature. rawBody: a Buffer or string, exactly as received.
function verify(secret, header, rawBody, toleranceSeconds = 300, now = Date.now() / 1000) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const sentAt = Number(parts.t);
if (!sentAt || !parts.v1 || Math.abs(now - sentAt) > toleranceSeconds) return false; // too old: it may be a replay
const expected = crypto.createHmac("sha256", secret).update(sentAt + ".").update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { verify };A worked example you can test with
With this secret, body and time, the header is the one shown. Both functions above return true for it (with now set to t).
secret whsec_k3Jp9Xv2Qm7LwR5tY8nB1cD4eF6gH0aS
t 1759675391
body {"id":"evt_5f3a91c07d2e4b8a6c1d4133","kind":"ping","created_at":"2026-10-05T14:03:11Z","company_id":1,"data":{"message":"This is a test from LeaveCalc. Your address works.","webhook_id":7}}
header X-LeaveCalc-Signature: t=1759675391,v1=3fcaab93858460e63c16f5fb4c294aa7d4ee51e5d2edd5ee3641807bbf2d08c4If you change the secret with New secret in the app, deliveries are signed with the new one from then on. A webhook made for Zapier or Make does not need this check.
Answers and retries
Answer with any 2xx status within 5 seconds. Do your slow work after you have answered. Anything else is a failure: a 3xx (we do not follow redirects), 4xx, 5xx, a timeout or a connection error.
| Try | When |
|---|---|
| 1st send | At once, right after the change is saved |
| 2nd | 1 minute after the first failure |
| 3rd | 5 minutes later |
| 4th | 30 minutes later |
| 5th | 2 hours later |
| 6th | 12 hours later |
| 7th | 24 hours later, then we give up (the delivery is marked dead) |
- If an address has only failed for 3 days, we switch it off and email the owners once: "We stopped sending to your host because it kept failing. Turn it back on in Settings."
- In the app each webhook has a short log (time, event, result) and Send again. The log is kept for 30 days. A test is sent with Send test and is not retried.
- Messages can arrive twice and not always in order. Use the event
idto ignore a repeat andcreated_atto order them. - We read at most 64 KB of your answer and keep none of it. A message is at most 256 KB; ours are a few KB.
Events
Every event, with the body we send. The objects inside are the same shapes the API returns. Group by group: time off requests, people, licenses and certificates, extra time worked, attendance, and the test message.
| Event | Group | When |
|---|---|---|
request.created | Time off requests | A request is sent, from the app, the request link, the employee page or the API. |
request.first_approved | Time off requests | The first of two approvals is given (only if you use two-step approval). The request is still pending. |
request.approved | Time off requests | A request is approved (the final approval). |
request.denied | Time off requests | A request is not approved. |
request.cancelled | Time off requests | A request is cancelled. data.previous_status says whether it was pending or approved. |
request.edited | Time off requests | The dates, hours or leave type of a request were changed. data.material is true when it changed what counts. |
request.makeup_recorded | Time off requests | Make-up time is recorded on an approved request: hours worked afterwards to make up for it. data.makeup has the record and data.request the new makeup_total and effective_hours. |
request.makeup_removed | Time off requests | Make-up time is removed from a request. data.makeup is the record that was removed. |
employee.created | People | Someone is added: in the app, by import, in the first-run setup or through the API. |
employee.updated | People | A field of a person changed. data.changed lists the field names. Changes to pay, notes or the approver send nothing. |
employee.archived | People | Someone is archived (inactive or terminated). |
credential.expiring | Licenses and certificates | A license or certificate reached a reminder day. data.reminder is d60, d30, d7 or similar. The number and notes are never sent. |
credential.expired | Licenses and certificates | A license or certificate has expired. |
toil.created | Extra time worked | Extra time worked is recorded. |
toil.approved | Extra time worked | Extra time worked is approved and added as time off in lieu. |
toil.denied | Extra time worked | Extra time worked is not approved. |
attendance.step | Attendance | Someone reached an attendance step. Only the step name and the day are sent, never points. It is NOT part of "all events": name it to get it. |
ping | Test | The test message of the Send test button. It is sent whatever events the webhook has. |
request.created
A request is sent, from the app, the request link, the employee page or the API.
{
"id": "evt_5f3a91c07d2e4b8a6c1d3022",
"kind": "request.created",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 3,
"employee_id": 1,
"employee_name": "Ana Silva",
"employee_email": "ana.silva@example.com",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-23",
"end_date": "2026-11-25",
"hours": 24.0,
"days": 3.0,
"partial_day": false,
"status": "pending",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "api",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": null,
"makeup_total": 0.0,
"effective_hours": 24.0,
"effective_days": 3.0
},
"employee": {
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk"
}
}
}request.first_approved
The first of two approvals is given (only if you use two-step approval). The request is still pending.
{
"id": "evt_5f3a91c07d2e4b8a6c1db8aa",
"kind": "request.first_approved",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 2,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-02",
"end_date": "2026-11-06",
"hours": 32.0,
"days": 4.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 32.0,
"effective_days": 4.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}request.approved
A request is approved (the final approval).
{
"id": "evt_5f3a91c07d2e4b8a6c1d4133",
"kind": "request.approved",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 2,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-02",
"end_date": "2026-11-06",
"hours": 32.0,
"days": 4.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 32.0,
"effective_days": 4.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}request.denied
A request is not approved.
{
"id": "evt_5f3a91c07d2e4b8a6c1d5244",
"kind": "request.denied",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 3,
"employee_id": 1,
"employee_name": "Ana Silva",
"employee_email": "ana.silva@example.com",
"department": "Front desk",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-23",
"end_date": "2026-11-25",
"hours": 24.0,
"days": 3.0,
"partial_day": false,
"status": "denied",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "api",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 24.0,
"effective_days": 3.0
},
"employee": {
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk"
},
"previous_status": "pending"
}
}request.cancelled
A request is cancelled. data.previous_status says whether it was pending or approved.
{
"id": "evt_5f3a91c07d2e4b8a6c1d7466",
"kind": "request.cancelled",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 4,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-12-07",
"end_date": "2026-12-08",
"hours": 16.0,
"days": 2.0,
"partial_day": false,
"status": "cancelled",
"note": null,
"note_withheld": false,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 16.0,
"effective_days": 2.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"previous_status": "pending"
}
}request.edited
The dates, hours or leave type of a request were changed. data.material is true when it changed what counts.
{
"id": "evt_5f3a91c07d2e4b8a6c1d6355",
"kind": "request.edited",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 4,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-12-07",
"end_date": "2026-12-08",
"hours": 16.0,
"days": 2.0,
"partial_day": false,
"status": "pending",
"note": null,
"note_withheld": false,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": null,
"makeup_total": 0.0,
"effective_hours": 16.0,
"effective_days": 2.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"previous_status": "pending",
"edited_by": "manager",
"material": true
}
}request.makeup_recorded
Make-up time is recorded on an approved request: hours worked afterwards to make up for it. data.makeup has the record and data.request the new makeup_total and effective_hours.
{
"id": "evt_5f3a91c07d2e4b8a6c1d8577",
"kind": "request.makeup_recorded",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 2,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-02",
"end_date": "2026-11-06",
"hours": 32.0,
"days": 4.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 4.0,
"effective_hours": 28.0,
"effective_days": 3.5
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"previous_status": "approved",
"makeup": {
"id": 1,
"amount": 4.0,
"unit": "hours",
"hours": 4.0,
"worked_on": "2026-10-05",
"note": null,
"note_withheld": true
}
}
}request.makeup_removed
Make-up time is removed from a request. data.makeup is the record that was removed.
{
"id": "evt_5f3a91c07d2e4b8a6c1d9688",
"kind": "request.makeup_removed",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"request": {
"id": 2,
"employee_id": 2,
"employee_name": "Marcus Lee",
"employee_email": "marcus.lee@example.com",
"department": "Clinic",
"policy_id": 1,
"policy_name": "PTO",
"leave_type": "pto",
"start_date": "2026-11-02",
"end_date": "2026-11-06",
"hours": 32.0,
"days": 4.0,
"partial_day": false,
"status": "approved",
"note": null,
"note_withheld": true,
"decision_note": null,
"source": "manager",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z",
"decided_at": "2026-10-05T14:03:11Z",
"makeup_total": 0.0,
"effective_hours": 32.0,
"effective_days": 4.0
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"previous_status": "approved",
"makeup": {
"id": 1,
"amount": 4.0,
"unit": "hours",
"hours": 4.0,
"worked_on": "2026-10-05",
"note": null,
"note_withheld": true
}
}
}employee.created
Someone is added: in the app, by import, in the first-run setup or through the API.
{
"id": "evt_5f3a91c07d2e4b8a6c1d0e00",
"kind": "employee.created",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"employee": {
"id": 3,
"name": "Priya Shah",
"email": "priya.shah@example.com",
"department": "Clinic",
"location_id": null,
"location": null,
"start_date": "2026-10-19",
"end_date": null,
"hours_per_week": 24.0,
"working_days": [
"mon",
"tue",
"wed"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
}
}employee.updated
A field of a person changed. data.changed lists the field names. Changes to pay, notes or the approver send nothing.
{
"id": "evt_5f3a91c07d2e4b8a6c1d1f11",
"kind": "employee.updated",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"employee": {
"id": 3,
"name": "Priya Shah",
"email": "priya.shah@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2026-10-19",
"end_date": null,
"hours_per_week": 28.0,
"working_days": [
"mon",
"tue",
"wed"
],
"status": "active",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
},
"changed": [
"department",
"hours_per_week"
]
}
}employee.archived
Someone is archived (inactive or terminated).
{
"id": "evt_5f3a91c07d2e4b8a6c1da799",
"kind": "employee.archived",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"employee": {
"id": 3,
"name": "Priya Shah",
"email": "priya.shah@example.com",
"department": "Front desk",
"location_id": null,
"location": null,
"start_date": "2026-10-19",
"end_date": null,
"hours_per_week": 28.0,
"working_days": [
"mon",
"tue",
"wed"
],
"status": "inactive",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
}
}
}credential.expiring
A license or certificate reached a reminder day. data.reminder is d60, d30, d7 or similar. The number and notes are never sent.
{
"id": "evt_5f3a91c07d2e4b8a6c1dc9bb",
"kind": "credential.expiring",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"credential": {
"id": 1,
"type": "CPR certification",
"category": "certification",
"expires_on": "2026-10-26",
"days_left": 21,
"status": "expiring"
},
"employee": {
"id": 1,
"name": "Ana Silva",
"email": "ana.silva@example.com",
"department": "Front desk"
},
"reminder": "d30"
}
}credential.expired
A license or certificate has expired.
{
"id": "evt_5f3a91c07d2e4b8a6c1ddacc",
"kind": "credential.expired",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"credential": {
"id": 2,
"type": "CPR certification",
"category": "certification",
"expires_on": "2026-09-30",
"days_left": -5,
"status": "expired"
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"reminder": "expired"
}
}toil.created
Extra time worked is recorded.
{
"id": "evt_5f3a91c07d2e4b8a6c1debdd",
"kind": "toil.created",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"entry": {
"id": 1,
"worked_on": "2026-10-03",
"hours_worked": 4.0,
"hours_credited": 6.0,
"status": "pending",
"expires_on": "2027-01-01",
"reason": "Inventory count",
"policy_name": "Comp time",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}toil.approved
Extra time worked is approved and added as time off in lieu.
{
"id": "evt_5f3a91c07d2e4b8a6c1dfcee",
"kind": "toil.approved",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"entry": {
"id": 1,
"worked_on": "2026-10-03",
"hours_worked": 4.0,
"hours_credited": 6.0,
"status": "approved",
"expires_on": "2027-01-01",
"reason": "Inventory count",
"policy_name": "Comp time",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}toil.denied
Extra time worked is not approved.
{
"id": "evt_5f3a91c07d2e4b8a6c1e0dff",
"kind": "toil.denied",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"entry": {
"id": 2,
"worked_on": "2026-10-04",
"hours_worked": 2.0,
"hours_credited": 3.0,
"status": "denied",
"expires_on": "2027-01-02",
"reason": null,
"policy_name": "Comp time",
"created_at": "2026-10-05T14:03:11Z",
"updated_at": "2026-10-05T14:03:11Z"
},
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
}
}
}attendance.step
Someone reached an attendance step. Only the step name and the day are sent, never points. It is NOT part of "all events": name it to get it.
{
"id": "evt_5f3a91c07d2e4b8a6c1e1f10",
"kind": "attendance.step",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"employee": {
"id": 2,
"name": "Marcus Lee",
"email": "marcus.lee@example.com",
"department": "Clinic"
},
"steps": [
{
"name": "Verbal warning"
}
],
"reached_on": "2026-09-22"
}
}ping
The test message of the Send test button. It is sent whatever events the webhook has.
{
"id": "evt_5f3a91c07d2e4b8a6c1e3021",
"kind": "ping",
"created_at": "2026-10-05T14:03:11Z",
"company_id": 1,
"data": {
"message": "This is a test from LeaveCalc. Your address works.",
"webhook_id": 1
}
}Limits
| What | Limit |
|---|---|
| Requests | 120 a minute for each token (429 with Retry-After beyond that). The count is kept in memory by the server, so a restart starts it again. |
| Page size | Default 50, at most 200. |
| Tokens | Up to 10 live tokens for each company, and 300 requests a minute for all of them together. |
| Webhooks | Up to 10 for each company. |
| Webhook message | At most 256 KB. Ours are a few KB. |
| Time to answer a webhook | 5 seconds. We read at most 64 KB of your answer and keep none of it. |
| Addresses | https only, on the public internet. We do not follow redirects. |
Changelog
v1, October 2026
- First public release of the API: tokens, people, leave types, balances, requests, who is out, holidays, and webhooks.
- Webhook events: requests, people, licenses and certificates, extra time worked, attendance steps, and ping.
- Zapier and Make: use the generic Webhooks and HTTP modules today. A LeaveCalc app for Zapier is prepared and not yet listed.
- AI assistants: a remote MCP server at /api/v1/mcp takes the same tokens. It works with Claude Code and other tools that send a fixed token. Sign-in with OAuth is not available yet. See the AI assistants (MCP) page.
The version is in the name of the path (/api/v1/). A change that could break you would be a v2, announced here first. Questions: use Ask the developer inside the app.