---
title: "LekhaHR API reference: people, attendance, leave and payroll"
description: "The LekhaHR API: read people, attendance, leave and pay run totals, add people and device punches, and check webhook signatures. Keys, scopes and limits."
canonical: https://lekhahr.in/developers/api/
last_updated: 2026-10-21
---
> Design preview · sample data. Product previews and filled examples on this page use one sample company, Paiteq Technologies, and its people. They are not a LekhaHR customer, a testimonial or usage figures.

Developers · API v1

# LekhaHR API

Read your people, attendance, leave and pay run totals, add people and device punches, and hear about changes through webhooks. For your company's own tools: keys are made in Settings and checked by a second admin.

- **Base address**: api.lekhahr.in/v1
- **Format**: JSON over HTTPS
- **Plan**: Business, and trials

## Quick start

1. Make a key in Settings › API keys and webhooks. A second admin checks it, then its owner copies it once.
2. Call `GET /v1/company` with it from a server. The answer names your company and the key's scopes.

Every answer is JSON in UTF-8 with `snake_case` fields. Dates are `YYYY-MM-DD` in the company's timezone, instants are UTC (`2026-11-04T06:30:00Z`), money is in paise with `"currency": "INR"`, and ids are UUIDs. Lists come in pages: send `limit` (1 to 200, default 50) and the last page's `next_cursor` as `cursor`. A cursor lasts 24 hours.

[Download openapi.json (OpenAPI 3.1)](https://lekhahr.in/developers/api/openapi.json)

Check your key

```
curl https://api.lekhahr.in/v1/company \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "company": {
    "id": "6a3d9c1e-2f4b-4e8a-9d7c-1b2a3c4d5e6f",
    "name": "Example Technologies",
    "timezone": "Asia/Kolkata"
  },
  "key": {
    "id": "7QD2HX4MRT0B",
    "name": "Staff directory",
    "scopes": [
      "people.read"
    ],
    "coverage": {
      "kind": "company"
    },
    "expires_on": "2027-01-31"
  }
}
```

On this page

## Authentication

Send your key in the `Authorization` header as `Bearer lkh_live_…`. Keys are made in Settings › API keys and webhooks and shown once. Use them only from a server: the API sends no browser headers, so a web page can't call it, and a key in a web page would be visible to anyone.

A key works for one company and never does more than the person who owns it can do in LekhaHR. It covers the whole company or chosen groups, and someone outside its coverage answers `404`, as an id that doesn't exist does. If its owner leaves or loses that access, the key answers `key_paused` until someone with access takes it over. A key can also be limited to a list of calling addresses.

Every request

```
Authorization: Bearer lkh_live_<key id>_<secret>
```

## Scopes

A key holds one or more scopes. It never does more than the person who owns it can do in LekhaHR. Write scopes and `payroll.read` are elevated: the second admin sees them marked, and the maker confirms with a fresh code. `GET /v1/company` works with any key.

**Scopes and what each opens**

| Scope | Kind | Opens |
| --- | --- | --- |
| people.read | read | List people, One person, Departments and locations |
| people.write | write · elevated | Add a person (whole-company key) |
| attendance.read | read | Attendance days |
| attendance.write | write · elevated | Send punches |
| leave.read | read | Leave requests, Leave balances, Leave types |
| payroll.read | read · elevated | Pay runs, Payslip summaries (whole-company key) |

## Errors

Every error has the same shape. The message is one plain sentence and never repeats a value you sent; `fields` comes only with `422`.

**Fields of an error**

| Name | Type | What it is |
| --- | --- | --- |
| `error.code` | string | One of the codes below |
| `error.message` | string | One plain sentence; never a value you sent |
| `error.request_id` | string | Quote it if you contact support |
| `error.fields` | list | On 422 only: each field and a code |

**Error codes**

| Status | Code | What to do |
| --- | --- | --- |
| 400 | `malformed` | Check the method, query and headers. A write needs a JSON object body and an Idempotency-Key. |
| 401 | `invalid_key` | Check the key. Missing, revoked, ended and unknown keys all answer this. |
| 403 | `scope_missing` | Use a key with that scope. Payroll reads and adding people need a whole-company key. |
| 403 | `ip_not_allowed` | Call from an address on the key's list. |
| 403 | `plan_required` | The API is part of the Business plan. |
| 403 | `plan_read_only` | The company's plan allows reads only until it is active again. |
| 403 | `key_paused` | The key's owner left or lost access. Someone with access takes it over in Settings. |
| 404 | `not_found` | The id isn't in this key's company or coverage, or the company doesn't use that module. |
| 409 | `request_in_progress` | A request with this Idempotency-Key is still running. Send it again in a moment. |
| 409 | `not_released` | Payslip summaries appear once the run is released. |
| 409 | `try_again` | Nothing was saved. Send the same request again. |
| 410 | `cursor_expired` | Start the list again without the cursor. |
| 422 | `validation_failed` | Fix the fields named in fields. |
| 422 | `idempotency_key_reused` | Use a new Idempotency-Key for a different request. |
| 429 | `rate_limited` | Wait for the Retry-After seconds, then send it again. |
| 500 | `server_error` | Try again later. Quote request_id if it keeps happening. |

Answer · 422

```
{
  "error": {
    "code": "validation_failed",
    "message": "Some fields are not valid.",
    "request_id": "4e5b6a7c",
    "fields": [
      {
        "field": "work_email",
        "code": "email_taken"
      }
    ]
  }
}
```

## Rate limits

Counted per minute. A `429` carries `Retry-After`; every answer carries `RateLimit-Policy`. No remaining count is sent.

**Rate limits**

| Counted for | Limit | When |
| --- | --- | --- |
| Each calling address | 300 a minute | Before the key is read |
| Each key, reads | 120 a minute | After the key is checked |
| Each key, writes | 30 a minute | After the key is checked |
| Each company | 600 a minute | Across all its keys |

Headers on every answer

```
RateLimit-Policy: 120;w=60
Cache-Control: private, no-store

# writes
RateLimit-Policy: 30;w=60

# only on 429
Retry-After: 60
```

## Idempotency

Every `POST` needs an `Idempotency-Key` header: 1 to 64 letters, digits, `-` or `_`. Send the same key again with the same body and you get the first answer again, with `Idempotent-Replayed: true`; nothing is written twice. Keys are kept 24 hours. A body that fails its checks with `422 validation_failed` isn't kept, so the corrected body can use the same key. Bodies are JSON objects of at most 256 KB.

**Sending an Idempotency-Key again**

| You send | Answer | What it means |
| --- | --- | --- |
| Same key, same body | The first answer again | Safe to retry after a timeout |
| Same key, other body | `422 idempotency_key_reused` | Use a new key for a new request |
| Same key, first still running | `409 request_in_progress` | Wait a moment and send it again |

A retry

```
# the first try timed out: send it again
POST /v1/people
Idempotency-Key: join-EX-0043

# the answer
HTTP/1.1 201 Created
Idempotent-Replayed: true
```

## Versions

The version is in the path. Inside v1, changes only add: new fields, endpoints, events and values in lists marked open. A breaking change ships as v2, with v1 kept 12 months and `Deprecation` and `Sunset` headers from that day. Every change is in the changelog.

GET `/v1/company`

## Company

The key's own company and the key's facts. Any working key may call it; use it to check a new key. Works with any key.

Never the key owner's name or email, and never another key.

### Answer

**GET /v1/company: Answer**

| Name | Type | What it is |
| --- | --- | --- |
| `company.id` | uuid | Your company's id |
| `company.name` | string | As in Settings |
| `company.timezone` | string | Dates in every answer are in this timezone |
| `key.id` | string | The key's public 12-character id |
| `key.name` | string | As made in Settings |
| `key.scopes` | list | What the key may call |
| `key.coverage` | object | kind company, or kind groups with each group's id, kind and name |
| `key.expires_on` | date | The last day the key works |

Errors: `400 malformed`, `401 invalid_key`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `429 rate_limited`, `500 server_error`

Company

```
curl https://api.lekhahr.in/v1/company \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "company": {
    "id": "6a3d9c1e-2f4b-4e8a-9d7c-1b2a3c4d5e6f",
    "name": "Example Technologies",
    "timezone": "Asia/Kolkata"
  },
  "key": {
    "id": "7QD2HX4MRT0B",
    "name": "Staff directory",
    "scopes": [
      "people.read"
    ],
    "coverage": {
      "kind": "company"
    },
    "expires_on": "2027-01-31"
  }
}
```

GET `/v1/people`

## List people

People inside the key's coverage, ordered by id. Needs people.read.

Filter by status, department or office, or by updated_since for a nightly sync. No email, phone or identity number is returned.

### Parameters

**GET /v1/people parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `status` | string | active (the default), onboarding, on_notice, exited or all |
| `department_id` | uuid | Only this department |
| `location_id` | uuid | Only this office |
| `updated_since` | instant | Changed at or after this time, for a nightly sync |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each person

**GET /v1/people: Each person**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | Stays the same for the person's whole record |
| `employee_code` | string | Your code, as on the record |
| `full_name` | string | As on the record |
| `designation` | string or null | Job title |
| `department` | object or null | id and name |
| `location` | object or null | The office's id and name |
| `manager_id` | uuid or null | An id only, even when the manager is outside the key's coverage |
| `status` | string | active, onboarding, on_notice or exited |
| `date_of_joining` | date or null | As on the record |
| `last_working_day` | date or null | Set once an exit is approved |
| `updated_at` | instant | When the person's record last changed, UTC. A department or manager move alone does not change it |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

List people

```
curl "https://api.lekhahr.in/v1/people?status=active&limit=50" \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
      "employee_code": "EX-0042",
      "full_name": "Sample Person",
      "designation": "Engineer",
      "department": {
        "id": "2b4f6a8c-1d3e-4f50-8a6b-7c8d9e0f1a2b",
        "name": "Engineering"
      },
      "location": {
        "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
        "name": "Bengaluru"
      },
      "manager_id": "3f0a1b2c-4d5e-4f6a-9b7c-8d9e0f1a2b3c",
      "status": "active",
      "date_of_joining": "2025-06-02",
      "last_working_day": null,
      "updated_at": "2026-11-03T05:10:00Z"
    }
  ],
  "next_cursor": "eyJ2IjoxLCJrIjoiN1FEMkhYNE1SVDBCIn0.c_9QmTx2"
}
```

GET `/v1/people/{id}`

## One person

One person, with the same fields as the list. Needs people.read.

Someone outside the key's coverage answers 404, the same as an id that does not exist.

### Parameters

**GET /v1/people/{id} parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The person's id, in the path |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `429 rate_limited`, `500 server_error`

One person

```
curl https://api.lekhahr.in/v1/people/7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
  "employee_code": "EX-0042",
  "full_name": "Sample Person",
  "designation": "Engineer",
  "department": {
    "id": "2b4f6a8c-1d3e-4f50-8a6b-7c8d9e0f1a2b",
    "name": "Engineering"
  },
  "location": {
    "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
    "name": "Bengaluru"
  },
  "manager_id": "3f0a1b2c-4d5e-4f6a-9b7c-8d9e0f1a2b3c",
  "status": "active",
  "date_of_joining": "2025-06-02",
  "last_working_day": null,
  "updated_at": "2026-11-03T05:10:00Z"
}
```

POST `/v1/people`

## Add a person

Adds one person, as Add people does in the app. Needs a whole-company key. Needs people.write (elevated).

Before the date of joining the person is onboarding; from it, active. The code is the next free one unless you send one.

Never sends an invite, and never sets pay, bank details, identity numbers or a role.

A 422 names each field with a code: name_required, name_length, email_invalid, email_taken, contact_required, phone_invalid, department_not_found, location_not_found, manager_not_found, code_invalid, code_taken, or head_office_missing on company.

### Parameters

**POST /v1/people parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `Idempotency-Key` | header | 1 to 64 letters, digits, - or _. Kept 24 hours |

### Body

**POST /v1/people: Body**

| Name | Type | What it is |
| --- | --- | --- |
| `full_name` | string | Required, 2 to 80 characters |
| `work_email` | string | Send this or mobile, or both. Never read back |
| `mobile` | string | 10 digits, or +91 and 10 digits. Never read back |
| `date_of_joining` | date | Required. Before today the person is active; today or later, onboarding |
| `department_id` | uuid | Required. An existing department |
| `location_id` | uuid | An office; the head office when left out |
| `designation` | string | Job title, up to 80 characters kept |
| `manager_id` | uuid | Someone in the company |
| `employee_code` | string | The next free code when left out. Letters, digits and _. / -, up to 30 |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `403 plan_read_only`, `404 not_found`, `409 request_in_progress`, `422 validation_failed`, `422 idempotency_key_reused`, `429 rate_limited`, `500 server_error`

Add a person

```
curl https://api.lekhahr.in/v1/people \
  -H "Authorization: Bearer $LEKHAHR_KEY" \
  -H "Idempotency-Key: join-EX-0043" \
  -H "Content-Type: application/json" \
  -d '{
    "full_name": "Sample Person",
    "work_email": "sample.person@example.com",
    "date_of_joining": "2026-11-16",
    "department_id": "2b4f6a8c-1d3e-4f50-8a6b-7c8d9e0f1a2b",
    "location_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
    "designation": "Engineer",
    "manager_id": "3f0a1b2c-4d5e-4f6a-9b7c-8d9e0f1a2b3c"
  }'
```

Answer · 201

```
{
  "id": "1e2d3c4b-5a69-4788-9a0b-1c2d3e4f5a6b",
  "employee_code": "EX-0043",
  "full_name": "Sample Person",
  "designation": "Engineer",
  "department": {
    "id": "2b4f6a8c-1d3e-4f50-8a6b-7c8d9e0f1a2b",
    "name": "Engineering"
  },
  "location": {
    "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
    "name": "Bengaluru"
  },
  "manager_id": "3f0a1b2c-4d5e-4f6a-9b7c-8d9e0f1a2b3c",
  "status": "onboarding",
  "date_of_joining": "2026-11-16",
  "last_working_day": null,
  "updated_at": "2026-11-04T06:30:00Z"
}
```

GET `/v1/departments`

## Departments and locations

Every department of the company, by name. Needs people.read.

Not filtered by the key's coverage: names only.

### Each department or office

**GET /v1/departments: Each department or office**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The department's or office's id |
| `name` | string | As in Settings |
| `active` | boolean | true for every one listed |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `429 rate_limited`, `500 server_error`

GET `/v1/locations`

Every office of the company, by name. Needs people.read.

Not filtered by the key's coverage: names only.

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `429 rate_limited`, `500 server_error`

GET /v1/departments

```
curl https://api.lekhahr.in/v1/departments \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "2b4f6a8c-1d3e-4f50-8a6b-7c8d9e0f1a2b",
      "name": "Engineering",
      "active": true
    },
    {
      "id": "5d7e9f1a-3b4c-4d6e-8f0a-1b2c3d4e5f6a",
      "name": "Sales",
      "active": true
    }
  ]
}
```

GET /v1/locations

```
curl https://api.lekhahr.in/v1/locations \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b",
      "name": "Bengaluru",
      "active": true
    },
    {
      "id": "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
      "name": "Pune",
      "active": true
    }
  ]
}
```

GET `/v1/attendance/days`

## Attendance days

One row per person per worked-out day, for up to 31 days. Needs attendance.read.

status is one of present, half_day, absent, leave, holiday, weekly_off, on_duty, wfh, not_decided. A day on any leave reads leave. Never a location, photo, device or note.

### Parameters

**GET /v1/attendance/days parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `from` | date | Required. First day |
| `to` | date | Required. Last day, at most 31 days in all |
| `employee_id` | uuid | Only this person |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each day

**GET /v1/attendance/days: Each day**

| Name | Type | What it is |
| --- | --- | --- |
| `employee_id` | uuid | The person |
| `date` | date | The day, in the company's timezone |
| `status` | string | The day's outcome. New values may be added |
| `first_in` | HH:MM or null | First punch of the day |
| `last_out` | HH:MM or null | Last punch of the day |
| `worked_minutes` | integer | Minutes counted for the day |
| `late` | boolean | true when the first punch was late on a present or half day |
| `finalized` | boolean | false while the day can still change |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

Attendance days

```
curl "https://api.lekhahr.in/v1/attendance/days?from=2026-11-01&to=2026-11-30" \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "employee_id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
      "date": "2026-11-03",
      "status": "present",
      "first_in": "09:42",
      "last_out": "18:31",
      "worked_minutes": 489,
      "late": true,
      "finalized": true
    }
  ],
  "next_cursor": null
}
```

POST `/v1/attendance/punches`

## Send punches

Sends up to 500 device punches through an import source, as a device file import does. Needs attendance.write (elevated).

Punches are grouped per person and day in the source's timezone: the earliest in is the first swipe, the latest out the last. A day is imported once it has ended, and once per source.

200 when every punch is accepted, 207 with the same body when any is not.

Codes: unknown_person, cycle_locked, future_time, invalid, day_not_ended, too_far_back, on_leave, open_request, fixed, already_imported, not_active, own_day, day_not_ready. New codes may be added.

### Parameters

**POST /v1/attendance/punches parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `Idempotency-Key` | header | 1 to 64 letters, digits, - or _. Kept 24 hours |

### Body

**POST /v1/attendance/punches: Body**

| Name | Type | What it is |
| --- | --- | --- |
| `source` | string | Required. An import source's name, as set up in LekhaHR's device imports. It carries the timezone and badge map |
| `punches` | list | Required. 1 to 500 punches |

### Each punch

**POST /v1/attendance/punches: Each punch**

| Name | Type | What it is |
| --- | --- | --- |
| `external_id` | string | Required. Your device's own record id, 1 to 64 visible ASCII characters |
| `employee_id` | uuid | Name the person by id, code or badge: exactly one |
| `employee_code` | string | Their employee code |
| `badge_id` | string | The badge number, matched through the source's badge map |
| `at` | instant | Required. When, with its offset |
| `direction` | string | Required. in, out or unknown |

### Each result

**POST /v1/attendance/punches: Each result**

| Name | Type | What it is |
| --- | --- | --- |
| `external_id` | string | As you sent it |
| `result` | string | accepted, duplicate or rejected |
| `code` | string | Why, when it is not accepted. New values may be added |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `403 plan_read_only`, `409 request_in_progress`, `409 try_again`, `422 validation_failed`, `422 idempotency_key_reused`, `429 rate_limited`, `500 server_error`

Send punches

```
curl https://api.lekhahr.in/v1/attendance/punches \
  -H "Authorization: Bearer $LEKHAHR_KEY" \
  -H "Idempotency-Key: door-1-2026-11-03" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "Front door device",
    "punches": [
      {
        "external_id": "door-1-000981",
        "employee_code": "EX-0042",
        "at": "2026-11-03T09:42:10+05:30",
        "direction": "in"
      },
      {
        "external_id": "door-1-001207",
        "employee_code": "EX-0042",
        "at": "2026-11-03T18:31:02+05:30",
        "direction": "out"
      }
    ]
  }'
```

Answer · 200

```
{
  "results": [
    {
      "external_id": "door-1-000981",
      "result": "accepted"
    },
    {
      "external_id": "door-1-001207",
      "result": "accepted"
    }
  ]
}
```

207 has the same body: some punches were not accepted; each result says why.

GET `/v1/leave/requests`

## Leave requests

Leave requests that overlap the dates, for up to 366 days. Needs leave.read.

Never the reason, a note, an attachment or the approver. Protected leave is never listed, and rejected requests are left out.

### Parameters

**GET /v1/leave/requests parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `from` | date | Required |
| `to` | date | Required, at most 366 days in all |
| `status` | string | approved (the default), pending, cancelled or all |
| `employee_id` | uuid | Only this person |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each request

**GET /v1/leave/requests: Each request**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The request |
| `employee_id` | uuid | The person |
| `leave_type` | object | id and name |
| `from` | date | First day |
| `to` | date | Last day, included |
| `half_days` | list | Days taken as half days |
| `days` | number | Leave days inside the dates |
| `status` | string | pending, approved or cancelled |
| `requested_at` | instant | When it was sent, UTC |
| `decided_at` | instant or null | When it was approved, UTC |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

Leave requests

```
curl "https://api.lekhahr.in/v1/leave/requests?from=2026-11-01&to=2026-11-30" \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "5c6d7e8f-9a0b-4c1d-8e2f-3a4b5c6d7e8f",
      "employee_id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
      "leave_type": {
        "id": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d",
        "name": "Earned leave"
      },
      "from": "2026-11-16",
      "to": "2026-11-18",
      "half_days": [],
      "days": 3,
      "status": "approved",
      "requested_at": "2026-11-02T04:10:00Z",
      "decided_at": "2026-11-02T06:00:00Z"
    }
  ],
  "next_cursor": null
}
```

GET `/v1/leave/balances`

## Leave balances

Each person's balances on a date, today by default. Needs leave.read.

People who have left are not listed. Protected leave types are left out.

### Parameters

**GET /v1/leave/balances parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `employee_id` | uuid | Only this person |
| `as_of` | date | The date; today in the company's timezone by default |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each person

**GET /v1/leave/balances: Each person**

| Name | Type | What it is |
| --- | --- | --- |
| `employee_id` | uuid | The person |
| `as_of` | date | The date the balances are worked out for |
| `balances` | list | One row per leave type of their plan |

### Each balance

**GET /v1/leave/balances: Each balance**

| Name | Type | What it is |
| --- | --- | --- |
| `leave_type` | object | id and name |
| `available` | number | Days the person can still take |
| `booked` | number | Days taken or approved this leave year |
| `unit` | string | Always days |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

Leave balances

```
curl https://api.lekhahr.in/v1/leave/balances \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "employee_id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
      "as_of": "2026-11-04",
      "balances": [
        {
          "leave_type": {
            "id": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d",
            "name": "Earned leave"
          },
          "available": 10,
          "booked": 3,
          "unit": "days"
        },
        {
          "leave_type": {
            "id": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
            "name": "Sick leave"
          },
          "available": 6,
          "booked": 1,
          "unit": "days"
        }
      ]
    }
  ],
  "next_cursor": null
}
```

GET `/v1/leave/types`

## Leave types

Every leave type of the company, by name. Needs leave.read.

Protected leave types are left out. A request's leave_type.id is one of these.

### Each leave type

**GET /v1/leave/types: Each leave type**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The leave type |
| `name` | string | As in the leave plan |
| `unit` | string | Always days |
| `active` | boolean | true when a plan in force today holds it |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `429 rate_limited`, `500 server_error`

Leave types

```
curl https://api.lekhahr.in/v1/leave/types \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "4a5b6c7d-8e9f-4a0b-9c1d-2e3f4a5b6c7d",
      "name": "Earned leave",
      "unit": "days",
      "active": true
    },
    {
      "id": "6b7c8d9e-0f1a-4b2c-8d3e-4f5a6b7c8d9e",
      "name": "Sick leave",
      "unit": "days",
      "active": true
    }
  ]
}
```

GET `/v1/payroll/runs`

## Pay runs

Totals of locked and released pay runs. Needs a whole-company key. Needs payroll.read (elevated).

Totals only: never a component, a person or a bank detail. People whose pay is hidden are left out of the totals. Each run returned is recorded in the access log.

### Parameters

**GET /v1/payroll/runs parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `year` | string | Financial year by its start: 2026 is 1 Apr 2026 to 31 Mar 2027 |
| `pay_group_id` | uuid | Only this pay group |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each run

**GET /v1/payroll/runs: Each run**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The run |
| `period` | string | YYYY-MM, the month its pay window ends in |
| `pay_group` | object | id and name |
| `status` | string | locked or released |
| `headcount` | integer | People in the totals |
| `gross_paise` | integer | Gross pay, in paise |
| `deductions_paise` | integer | Deductions and tax, in paise |
| `net_paise` | integer | Net pay, in paise |
| `employer_contributions_paise` | integer | Employer cost on top of gross, in paise |
| `currency` | string | Always INR |
| `locked_at` | instant or null | When it was locked, UTC |
| `released_at` | instant or null | When payslips were released, UTC |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

Pay runs

```
curl "https://api.lekhahr.in/v1/payroll/runs?year=2026" \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "id": "8f9a0b1c-2d3e-4f4a-9b5c-6d7e8f9a0b1c",
      "period": "2026-10",
      "pay_group": {
        "id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
        "name": "Monthly"
      },
      "status": "released",
      "headcount": 41,
      "gross_paise": 5124800000,
      "deductions_paise": 612300000,
      "net_paise": 4512500000,
      "employer_contributions_paise": 301200000,
      "currency": "INR",
      "locked_at": "2026-10-28T09:00:00Z",
      "released_at": "2026-10-30T06:30:00Z"
    }
  ],
  "next_cursor": null
}
```

GET `/v1/payroll/runs/{id}/payslips`

## Payslip summaries

One row per person of a released run. Needs a whole-company key. Needs payroll.read (elevated).

Never components, bank account, PAN, UAN, tax regime, loans or notes. Each person returned is recorded in the access log.

### Parameters

**GET /v1/payroll/runs/{id}/payslips parameters**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | The run's id, in the path |
| `limit` | integer | 1 to 200. Default 50 |
| `cursor` | string | next_cursor of the last page; lasts 24 hours |

### Each payslip

**GET /v1/payroll/runs/{id}/payslips: Each payslip**

| Name | Type | What it is |
| --- | --- | --- |
| `employee_id` | uuid | The person |
| `gross_paise` | integer | Gross pay, in paise |
| `deductions_paise` | integer | Deductions and tax, in paise |
| `net_paise` | integer | Net pay, in paise |
| `paid_days` | number | Days paid |
| `lop_days` | number | Loss of pay days |
| `currency` | string | Always INR |

Errors: `400 malformed`, `401 invalid_key`, `403 scope_missing`, `403 ip_not_allowed`, `403 plan_required`, `403 key_paused`, `404 not_found`, `409 not_released`, `410 cursor_expired`, `429 rate_limited`, `500 server_error`

Payslip summaries

```
curl https://api.lekhahr.in/v1/payroll/runs/8f9a0b1c-2d3e-4f4a-9b5c-6d7e8f9a0b1c/payslips \
  -H "Authorization: Bearer $LEKHAHR_KEY"
```

Answer · 200

```
{
  "data": [
    {
      "employee_id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
      "gross_paise": 12500000,
      "deductions_paise": 1480000,
      "net_paise": 11020000,
      "paid_days": 31,
      "lop_days": 0,
      "currency": "INR"
    }
  ],
  "next_cursor": null
}
```

## Webhook events

A webhook is added in Settings › API keys and webhooks and linked to one key. It gets an event only when that key holds the event's scope and covers the person. Bodies carry ids, dates and a status: fetch the rest with the key. Retries keep the same `webhook-id`, and no order is promised.

**Webhook events**

| Event | Needs | When |
| --- | --- | --- |
| `employee.joined` | people.read | A person's record becomes active. Data: employee_id, date_of_joining. |
| `employee.updated` | people.read | Name, code, designation, department, location or manager changed, effective today or earlier. Data: employee_id, changed. |
| `employee.exit_scheduled` | people.read | An exit is approved with a last working day. Data: employee_id, last_working_day. |
| `employee.left` | people.read | The record moves to exited. Data: employee_id, last_working_day. |
| `leave.requested` | leave.read | A leave request is sent. Data: leave_request_id, employee_id, from, to. |
| `leave.approved` | leave.read | A request is approved. Data: leave_request_id, employee_id, from, to. |
| `leave.cancelled` | leave.read | An approved or pending request is cancelled or withdrawn. Data: leave_request_id, employee_id, from, to. |
| `attendance.day_finalized` | attendance.read | A person's day is finalised, or finalised again after a correction. Data: employee_id, date, status. |
| `payroll.run_locked` | payroll.read whole-company key | A pay run is locked. Data: payroll_run_id, period, pay_group_id. |
| `payroll.payslips_released` | payroll.read whole-company key | A run's payslips are released to people. Data: payroll_run_id, period, released_count. |
| `webhook.test` | any key | Someone presses Send test in Settings. |

### Every event

**Fields of every webhook event**

| Name | Type | What it is |
| --- | --- | --- |
| `id` | uuid | Equals the webhook-id header and stays the same on every retry: deduplicate on it |
| `type` | string | The event |
| `created_at` | string | When the change happened, UTC |
| `company_id` | uuid | Your company's id, so one receiver can serve several companies |
| `sequence` | integer | Grows with each change to the same record: drop an older one that arrives after a newer one |
| `data` | object | Ids, dates and a status only |

A delivery

```
POST /hooks/lekhahr HTTP/1.1
Content-Type: application/json
User-Agent: LekhaHR-Webhooks/1
webhook-id: 0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10
webhook-timestamp: 1793334600
webhook-signature: v1,76eIkM5iRIG1J0V+2jMv+8L2f/8lH39ucpkG52wCsFc=

{
  "id": "0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10",
  "type": "employee.left",
  "created_at": "2026-10-29T18:30:00Z",
  "company_id": "6a3d9c1e-2f4b-4e8a-9d7c-1b2a3c4d5e6f",
  "sequence": 4,
  "data": {
    "employee_id": "7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b",
    "last_working_day": "2026-10-30"
  }
}
```

## Check a signature

Each delivery has `webhook-id`, `webhook-timestamp` and `webhook-signature`, the Standard Webhooks scheme. Any Standard Webhooks library checks it with your `whsec_` secret, or use these lines. Check the raw body before parsing it, refuse a timestamp more than 5 minutes from your clock, and deduplicate on `webhook-id`.

### Worked example

**A signature worked through**

| Part | Value |
| --- | --- |
| Secret | `whsec_TEVLSEFIUi1FWEFNUExFLVdFQkhPT0stU0VDUkVUISE=` |
| webhook-id | `0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10` |
| webhook-timestamp | `1793334600` |
| Signed | `{webhook-id}.{webhook-timestamp}.{raw body}`, HMAC-SHA256 with the bytes after `whsec_`, base64-decoded |
| webhook-signature | `v1,76eIkM5iRIG1J0V+2jMv+8L2f/8lH39ucpkG52wCsFc=` |

After a secret is rotated, both are signed for 24 hours: two signatures, new first, separated by a space. Accept the delivery if any one matches.

Check a signature · Node

```
import { createHmac, timingSafeEqual } from "node:crypto";

// headers: the request's headers, lower-case names. rawBody: the body exactly as received, before any JSON parsing.
export function verifyWebhook(secret, headers, rawBody, now = Math.floor(Date.now() / 1000)) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  if (!/^\d{1,12}$/.test(timestamp) || Math.abs(now - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`), Buffer.from(rawBody)]);
  const expected = Buffer.from("v1," + createHmac("sha256", key).update(signed).digest("base64"));
  return headers["webhook-signature"].split(" ").some((candidate) => {
    const given = Buffer.from(candidate);
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```

What is signed, for the example

```
0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10.1793334600.{"id":"0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10","type":"employee.left","created_at":"2026-10-29T18:30:00Z","company_id":"6a3d9c1e-2f4b-4e8a-9d7c-1b2a3c4d5e6f","sequence":4,"data":{"employee_id":"7c1e5a2b-9d3f-4c6e-8a1b-2c3d4e5f6a7b","last_working_day":"2026-10-30"}}
```

## Retries and pauses

A `2xx` answer within 10 seconds is delivered. Anything else is tried again after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h: 8 tries in about 45 hours. A `410` stops the retries, and redirects are not followed.

After 24 hours with no success and 3 failed deliveries, the webhook pauses and its owner hears about it. New events wait up to 7 days for Resume, which sends them oldest first. Send again in Settings resends one delivery, with the same `webhook-id`, for 30 days.

## What the API never returns

Email or phone, ID numbers (PAN, Aadhaar, UAN), bank details, tax, medical detail or protected leave, attendance location or photos, documents, reasons and notes, or pay components. Pay appears only as run totals and payslip summaries, with payroll.read.

## Changelog

**Changes to the API**

| Date | Change |
| --- | --- |
| 21 Oct 2026 | v1: company, people, departments, locations, attendance days, leave requests, balances and types, pay runs and payslip summaries; add a person and send punches; ten webhook events. |
