Developers

LeaveCalc API and webhooks

Read people, leave types, balances and time off, add people and requests, and get a message the moment something happens. It is part of every plan, Free included.

Base address: https://leavecalc.com/api/v1/ext/

On this page

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.
What is never available. Pay rates, private notes, FMLA and other medical details, documents attached to requests (such as doctor’s notes, and even the fact that one is attached), attendance points, support messages, tokens and secrets are not in any answer and not in any webhook. By default the type of sick, FMLA and protected leave is withheld too: such a request reads 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

  1. 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.
  2. Try it. Put the token in a variable and ask who you are:
    Terminal
    export LEAVECALC_TOKEN="lc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    
    curl -s "https://leavecalc.com/api/v1/ext/me" \
      -H "Authorization: Bearer $LEAVECALC_TOKEN"
  3. 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:

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

Request
curl -s "https://leavecalc.com/api/v1/ext/me"
Answer
HTTP 401 Unauthorized
{
  "error": "missing_token",
  "message": "Send your token as \"Authorization: Bearer <token>\"."
}

A token that is wrong or removed

Request
curl -s "https://leavecalc.com/api/v1/ext/me" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

Request
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Nope"
}'
Answer
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/json with 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

Request
curl -s "https://leavecalc.com/api/v1/ext/me" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

StatuserrorWhen
401missing_tokenThere is no Authorization header.
401invalid_tokenThe token is wrong or was removed.
403insufficient_scopeA token that can only read tried to change something.
403not_api_webhookYou tried to remove a webhook that was made in the app.
400bad_...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.
400missing_...A required value is missing, for example missing_name or missing_start_date.
400unknown_fieldYou sent a field that cannot be set through the API (pay, notes, approvers and so on).
402seat_limitYour plan has no room for another active person. The answer includes seat_limit.
404not_foundThere is no such person, request, leave type or webhook in your company.
409email_existsSomeone with this email is already on the team. The answer includes employee_id.
409blackoutThe dates fall in a blackout period that blocks requests.
409too_many_endpointsYou already have 10 webhooks. Remove one first.
403owner_token_requiredThe activity log was asked for with a token that was not made by an owner.
429rate_limitedMore than 120 requests in a minute with this token. Wait for Retry-After seconds.

Not found

Request
curl -s "https://leavecalc.com/api/v1/ext/employees/99999" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
HTTP 404 Not Found
{
  "error": "not_found",
  "message": "Person not found."
}

A value is not valid

Request
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"
}'
Answer
HTTP 400 Bad Request
{
  "error": "bad_range",
  "message": "The end date is before the start date."
}

A field that cannot be set

Request
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
}'
Answer
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

Request
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"
}'
Answer
HTTP 409 Conflict
{
  "error": "email_exists",
  "message": "Someone with this email is already on your team.",
  "employee_id": 3
}

The plan has no room

Request
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Three"
}'
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/employees?limit=1" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/employees?limit=1&cursor=eyJvIjoiYXNjIiwiaSI6MX0" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/employees?updated_since=2026-10-01T00:00:00Z&limit=1" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/me" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
statusquerytextactive, inactive or terminated. Leave it out to get everyone.
departmentquerytextExact department name, any capitals.
emailquerytextExact email address, any capitals. Handy to find one person.
updated_sincequerydate or momentOnly 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.
limitquerywhole numberHow many items to return. Default 50, at most 200 (a larger number is treated as 200).
cursorquerytextThe next_cursor of the previous answer. Use it with the same filters and the same order.
orderqueryasc or descasc (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

Request
curl -s "https://leavecalc.com/api/v1/ext/employees?status=active&limit=2" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/employees?email=ana.silva@example.com" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
idpathwhole numberThe id of the person.

One person

Request
curl -s "https://leavecalc.com/api/v1/ext/employees/1" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
namebodytextRequired. Up to 120 characters.
emailbodytextOptional. A person with this email already on the team is a 409 email_exists, so a retried automation does not add them twice.
start_datebodydateOptional. The first day at work, like 2026-10-19. A date in the future means leave starts building from that day.
departmentbodytextOptional. Up to 80 characters.
locationbodytextOptional. The name of one of your locations, any capitals. Or use location_id.
location_idbodywhole numberOptional. The id of one of your locations.
hours_per_weekbodynumberOptional. 0.5 to 168. Default 40.
working_daysbodylistOptional. 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

Request
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"
  ]
}'
Answer
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

Request
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"
}'
Answer
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

Request
curl -s -X POST "https://leavecalc.com/api/v1/ext/employees" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Three"
}'
Answer
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.

NameWhereTypeWhat it is
idpathwhole numberThe id of the person.
name, email, start_date, department, location, location_id, hours_per_week, working_daysbodyas aboveAny of them.
statusbodytextactive, 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

Request
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
}'
Answer
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

Request
curl -s "https://leavecalc.com/api/v1/ext/policies" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
employee_idquerywhole numberOne person. Without it you get every active person.
as_ofquerydateThe day to work out the balance for. Default: today in the company time zone.
limitquerywhole numberHow many items to return. Default 50, at most 200 (a larger number is treated as 200).
cursorquerytextThe next_cursor of the previous answer. Use it with the same filters and the same order.
orderqueryasc or descasc (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

Request
curl -s "https://leavecalc.com/api/v1/ext/balances?employee_id=1&as_of=2026-10-05" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
statusquerytextpending, approved, denied or cancelled.
fromquerydateRequests that end on or after this day.
toquerydateRequests that start on or before this day.
employee_idquerywhole numberOne person.
updated_sincequerydate or momentOnly requests changed at or after this moment: sent, decided, edited or cancelled.
limitquerywhole numberHow many items to return. Default 50, at most 200 (a larger number is treated as 200).
cursorquerytextThe next_cursor of the previous answer. Use it with the same filters and the same order.
orderqueryasc or descasc (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

Request
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"
Answer
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.

NameWhereTypeWhat it is
idpathwhole numberThe id of the request.

One request

Request
curl -s "https://leavecalc.com/api/v1/ext/requests/1" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
employee_idbodywhole numberWho it is for. Or use employee_email.
employee_emailbodytextThe email of an active person.
policy_idbodywhole numberThe leave type. Or use policy.
policybodytextThe name of the leave type, any capitals.
start_datebodydateRequired.
end_datebodydateOptional. Default: the start date.
hoursbodynumberOptional. Only for part of a day.
notebodytextOptional. 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

Request
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"
}'
Answer
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

Request
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"
}'
Answer
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).

NameWhereTypeWhat it is
datequerydateOne day. Default: today in the company time zone.
fromquerydateStart of a range. Use from and to instead of date.
toquerydateEnd of a range.

Who is out on 13 October

Request
curl -s "https://leavecalc.com/api/v1/ext/whos-out?date=2026-10-13" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
yearquerywhole numberOnly holidays in this year.
location_idquerywhole numberThe holidays of that location. Without it, the company-wide list.

Holidays in 2026

Request
curl -s "https://leavecalc.com/api/v1/ext/holidays?year=2026" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
employee_idquerywhole numberOnly entries about this person.
kindquerytextOne kind of change: requests, balances, people, policies, toil, credentials, holidays, team, settings, billing, connections, reports or system.
actionquerytextOne or several action codes, separated by commas, for example request.approved,request.denied.
qquerytextEntries that mention this: a name, a leave type or a word.
fromquerydateEntries on or after this day, in the time zone of your company.
toquerydateEntries on or before this day.
hide_automaticquerytrue or falseLeave out the entries LeaveCalc writes by itself, such as sign-ins and follow-up balance rows.
limitquerywhole numberHow many items to return. Default 50, at most 200 (a larger number is treated as 200).
cursorquerytextThe next_cursor of the previous answer. Use it with the same filters and the same order.
orderqueryasc or descasc (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

Request
curl -s "https://leavecalc.com/api/v1/ext/audit?kind=people&order=desc&limit=2" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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.

NameWhereTypeWhat it is
urlbodytextRequired. The address to send to. https only, on the public internet.
eventsbodylistOptional. A list of event names, or ["*"] for everything except attendance. Default: everything. A single event can also be sent as event.
descriptionbodytextOptional. A name for you, up to 120 characters.

A company can have up to 10 webhooks, whether made in the app or here.

Subscribe

Request
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"
}'
Answer
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).

NameWhereTypeWhat it is
idpathwhole numberThe id from the subscribe answer.

Unsubscribe

Request
curl -s -X DELETE "https://leavecalc.com/api/v1/ext/webhooks/3" \
  -H "Authorization: Bearer $LEAVECALC_TOKEN"
Answer
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

FieldTypeNotes
idwhole numberThe person id.
nametext
emailtext or null
departmenttext or null
location_idwhole number or nullThe location, if the person has one.
locationtext or nullIts name.
start_datedate or nullThe first day at work.
end_datedate or nullThe last day, when someone has left.
hours_per_weeknumber
working_dayslistThe days this person works, Monday first: mon, tue, wed, thu, fri, sat, sun.
statustextactive, inactive or terminated.
created_atmomentUTC.
updated_atmomentUTC. Moves with every change of the person.

Time off request

FieldTypeNotes
idwhole number
employee_idwhole number
employee_nametext
employee_emailtext or null
departmenttext or null
policy_idwhole numberThe leave type.
policy_nametext
leave_typetextpto, 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_datedate
end_datedate
hoursnumberWorking hours counted for the request.
daysnumberThe same in days of that person.
partial_daytrue or falseTrue when hours was given for part of a day.
statustextpending, approved, denied or cancelled.
notetext or nullThe note of the person. Null unless the token or webhook may see notes, and always null for sick leave, FMLA and protected leave.
note_withheldtrue or falseTrue when there is a note that we do not give out.
decision_notetext or nullWhat the approver wrote. Null for sick leave and FMLA.
sourcetextmanager, link, portal or api: where the request came from.
created_atmoment
updated_atmomentMoves with every change: decision, edit, cancel.
decided_atmoment or nullWhen it was approved, denied or cancelled.
makeup_totalnumberHours worked afterwards to make up for this time off (make-up time recorded in the app). 0 when there is none.
effective_hoursnumberWhat the request counts for: hours minus makeup_total. Balances, reports and exports use this number.
effective_daysnumberThe same in days of that person.

Leave type

FieldTypeNotes
idwhole number
nametext
leave_typetextThe kind: pto, sick, fmla, unpaid, holiday and so on.
paidtrue or false
tracks_balancetrue or falseFalse for leave types such as unpaid.
accrual_methodtextnone, per_hours_worked, per_pay_period, lump_sum or toil.
unittexthours or days.
location_idwhole number or nullSet when the leave type only applies to one location.

Balance of one person

FieldTypeNotes
employee_idwhole number
employee_nametext
statustextThe status of the person.
balanceslistOne 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

FieldTypeNotes
request_idwhole number
employee_idwhole number
employee_nametext
departmenttext or null
policy_idwhole number
policy_nametext
leave_typetext
start_datedate
end_datedate
partial_daytrue or false
hoursnumberThe hours of the request.
effective_hoursnumberWhat the request counts for after make-up time. The same as hours when nothing was made up.

Activity log entry

FieldTypeNotes
idwhole numberGrows with time. Use it with next_cursor.
created_atmomentUTC.
actiontextA short code such as request.approved.
kindtextThe kind of change, for example requests or people.
sentencetextWhat happened, ready to show to a person.
actorobjectWho 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_idwhole number or nullThe person the entry is about, if it is about a person.
employee_nametext or null
changeslistWhat changed: label and before and after for an edit, or label and value for something new. Can be empty.
automatictrue or falseTrue for entries LeaveCalc writes by itself.

Holiday

FieldTypeNotes
idwhole number or null
datedate
nametext
location_idwhole number or nullNull 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

  1. 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.
  2. Or by API, with a token that can read and change:
    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"
    }'
    Answer
    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"
    }
  3. 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.
  4. You can have up to 10 webhooks. Attendance steps are never part of "all events": name attendance.step if you want it.
  5. 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:

FieldTypeNotes
idtextA unique id for the event, like evt_5f3a... The same event sent to two webhooks has the same id. Use it to ignore duplicates.
kindtextThe event name, see the list below.
created_atmomentWhen it happened, UTC.
company_idwhole numberYour company.
dataobjectThe objects of this event, in the shapes above.

Headers:

HeaderValue
Content-Typeapplication/json; charset=utf-8
User-AgentLeaveCalc-Webhooks/1.0
X-LeaveCalc-EventThe same as kind.
X-LeaveCalc-DeliveryThe id of this delivery. A new delivery id is used each time we send again from the app.
X-LeaveCalc-Signaturet=<unix seconds>,v1=<hex>. See the next part.

The body is compact JSON. Below it is shown indented to be readable.

request.approved
{
  "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.

  1. Read t and v1 from the header.
  2. Refuse the message if t is more than 5 minutes from your clock (a replay).
  3. 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.
  4. Compare with v1 in constant time.

Python

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

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

Example
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=3fcaab93858460e63c16f5fb4c294aa7d4ee51e5d2edd5ee3641807bbf2d08c4

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

TryWhen
1st sendAt once, right after the change is saved
2nd1 minute after the first failure
3rd5 minutes later
4th30 minutes later
5th2 hours later
6th12 hours later
7th24 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 id to ignore a repeat and created_at to 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.

EventGroupWhen
request.createdTime off requestsA request is sent, from the app, the request link, the employee page or the API.
request.first_approvedTime off requestsThe first of two approvals is given (only if you use two-step approval). The request is still pending.
request.approvedTime off requestsA request is approved (the final approval).
request.deniedTime off requestsA request is not approved.
request.cancelledTime off requestsA request is cancelled. data.previous_status says whether it was pending or approved.
request.editedTime off requestsThe dates, hours or leave type of a request were changed. data.material is true when it changed what counts.
request.makeup_recordedTime off requestsMake-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_removedTime off requestsMake-up time is removed from a request. data.makeup is the record that was removed.
employee.createdPeopleSomeone is added: in the app, by import, in the first-run setup or through the API.
employee.updatedPeopleA field of a person changed. data.changed lists the field names. Changes to pay, notes or the approver send nothing.
employee.archivedPeopleSomeone is archived (inactive or terminated).
credential.expiringLicenses and certificatesA license or certificate reached a reminder day. data.reminder is d60, d30, d7 or similar. The number and notes are never sent.
credential.expiredLicenses and certificatesA license or certificate has expired.
toil.createdExtra time workedExtra time worked is recorded.
toil.approvedExtra time workedExtra time worked is approved and added as time off in lieu.
toil.deniedExtra time workedExtra time worked is not approved.
attendance.stepAttendanceSomeone 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.
pingTestThe 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.

request.created
{
  "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.

request.first_approved
{
  "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).

request.approved
{
  "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.

request.denied
{
  "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.

request.cancelled
{
  "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.

request.edited
{
  "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.

request.makeup_recorded
{
  "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.

request.makeup_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.

employee.created
{
  "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.

employee.updated
{
  "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).

employee.archived
{
  "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.

credential.expiring
{
  "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.

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

toil.created
{
  "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.

toil.approved
{
  "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.

toil.denied
{
  "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.

attendance.step
{
  "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.

ping
{
  "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

WhatLimit
Requests120 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 sizeDefault 50, at most 200.
TokensUp to 10 live tokens for each company, and 300 requests a minute for all of them together.
WebhooksUp to 10 for each company.
Webhook messageAt most 256 KB. Ours are a few KB.
Time to answer a webhook5 seconds. We read at most 64 KB of your answer and keep none of it.
Addresseshttps 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.