Skip to content
Request an invite

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)

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

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
ScopeKindOpens
people.readreadList people, One person, Departments and locations
people.writewrite · elevatedAdd a person (whole-company key)
attendance.readreadAttendance days
attendance.writewrite · elevatedSend punches
leave.readreadLeave requests, Leave balances, Leave types
payroll.readread · elevatedPay 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
NameTypeWhat it is
error.codestringOne of the codes below
error.messagestringOne plain sentence; never a value you sent
error.request_idstringQuote it if you contact support
error.fieldslistOn 422 only: each field and a code
Error codes
StatusCodeWhat to do
400malformedCheck the method, query and headers. A write needs a JSON object body and an Idempotency-Key.
401invalid_keyCheck the key. Missing, revoked, ended and unknown keys all answer this.
403scope_missingUse a key with that scope. Payroll reads and adding people need a whole-company key.
403ip_not_allowedCall from an address on the key's list.
403plan_requiredThe API is part of the Business plan.
403plan_read_onlyThe company's plan allows reads only until it is active again.
403key_pausedThe key's owner left or lost access. Someone with access takes it over in Settings.
404not_foundThe id isn't in this key's company or coverage, or the company doesn't use that module.
409request_in_progressA request with this Idempotency-Key is still running. Send it again in a moment.
409not_releasedPayslip summaries appear once the run is released.
409try_againNothing was saved. Send the same request again.
410cursor_expiredStart the list again without the cursor.
422validation_failedFix the fields named in fields.
422idempotency_key_reusedUse a new Idempotency-Key for a different request.
429rate_limitedWait for the Retry-After seconds, then send it again.
500server_errorTry 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 forLimitWhen
Each calling address300 a minuteBefore the key is read
Each key, reads120 a minuteAfter the key is checked
Each key, writes30 a minuteAfter the key is checked
Each company600 a minuteAcross 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 sendAnswerWhat it means
Same key, same bodyThe first answer againSafe to retry after a timeout
Same key, other body422 idempotency_key_reusedUse a new key for a new request
Same key, first still running409 request_in_progressWait 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
NameTypeWhat it is
company.iduuidYour company's id
company.namestringAs in Settings
company.timezonestringDates in every answer are in this timezone
key.idstringThe key's public 12-character id
key.namestringAs made in Settings
key.scopeslistWhat the key may call
key.coverageobjectkind company, or kind groups with each group's id, kind and name
key.expires_ondateThe 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
NameTypeWhat it is
statusstringactive (the default), onboarding, on_notice, exited or all
department_iduuidOnly this department
location_iduuidOnly this office
updated_sinceinstantChanged at or after this time, for a nightly sync
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each person

GET /v1/people: Each person
NameTypeWhat it is
iduuidStays the same for the person's whole record
employee_codestringYour code, as on the record
full_namestringAs on the record
designationstring or nullJob title
departmentobject or nullid and name
locationobject or nullThe office's id and name
manager_iduuid or nullAn id only, even when the manager is outside the key's coverage
statusstringactive, onboarding, on_notice or exited
date_of_joiningdate or nullAs on the record
last_working_daydate or nullSet once an exit is approved
updated_atinstantWhen 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
NameTypeWhat it is
iduuidThe 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
NameTypeWhat it is
Idempotency-Keyheader1 to 64 letters, digits, - or _. Kept 24 hours

Body

POST /v1/people: Body
NameTypeWhat it is
full_namestringRequired, 2 to 80 characters
work_emailstringSend this or mobile, or both. Never read back
mobilestring10 digits, or +91 and 10 digits. Never read back
date_of_joiningdateRequired. Before today the person is active; today or later, onboarding
department_iduuidRequired. An existing department
location_iduuidAn office; the head office when left out
designationstringJob title, up to 80 characters kept
manager_iduuidSomeone in the company
employee_codestringThe 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
NameTypeWhat it is
iduuidThe department's or office's id
namestringAs in Settings
activebooleantrue 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
NameTypeWhat it is
fromdateRequired. First day
todateRequired. Last day, at most 31 days in all
employee_iduuidOnly this person
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each day

GET /v1/attendance/days: Each day
NameTypeWhat it is
employee_iduuidThe person
datedateThe day, in the company's timezone
statusstringThe day's outcome. New values may be added
first_inHH:MM or nullFirst punch of the day
last_outHH:MM or nullLast punch of the day
worked_minutesintegerMinutes counted for the day
latebooleantrue when the first punch was late on a present or half day
finalizedbooleanfalse 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
NameTypeWhat it is
Idempotency-Keyheader1 to 64 letters, digits, - or _. Kept 24 hours

Body

POST /v1/attendance/punches: Body
NameTypeWhat it is
sourcestringRequired. An import source's name, as set up in LekhaHR's device imports. It carries the timezone and badge map
puncheslistRequired. 1 to 500 punches

Each punch

POST /v1/attendance/punches: Each punch
NameTypeWhat it is
external_idstringRequired. Your device's own record id, 1 to 64 visible ASCII characters
employee_iduuidName the person by id, code or badge: exactly one
employee_codestringTheir employee code
badge_idstringThe badge number, matched through the source's badge map
atinstantRequired. When, with its offset
directionstringRequired. in, out or unknown

Each result

POST /v1/attendance/punches: Each result
NameTypeWhat it is
external_idstringAs you sent it
resultstringaccepted, duplicate or rejected
codestringWhy, 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
NameTypeWhat it is
fromdateRequired
todateRequired, at most 366 days in all
statusstringapproved (the default), pending, cancelled or all
employee_iduuidOnly this person
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each request

GET /v1/leave/requests: Each request
NameTypeWhat it is
iduuidThe request
employee_iduuidThe person
leave_typeobjectid and name
fromdateFirst day
todateLast day, included
half_dayslistDays taken as half days
daysnumberLeave days inside the dates
statusstringpending, approved or cancelled
requested_atinstantWhen it was sent, UTC
decided_atinstant or nullWhen 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
NameTypeWhat it is
employee_iduuidOnly this person
as_ofdateThe date; today in the company's timezone by default
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each person

GET /v1/leave/balances: Each person
NameTypeWhat it is
employee_iduuidThe person
as_ofdateThe date the balances are worked out for
balanceslistOne row per leave type of their plan

Each balance

GET /v1/leave/balances: Each balance
NameTypeWhat it is
leave_typeobjectid and name
availablenumberDays the person can still take
bookednumberDays taken or approved this leave year
unitstringAlways 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
NameTypeWhat it is
iduuidThe leave type
namestringAs in the leave plan
unitstringAlways days
activebooleantrue 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
NameTypeWhat it is
yearstringFinancial year by its start: 2026 is 1 Apr 2026 to 31 Mar 2027
pay_group_iduuidOnly this pay group
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each run

GET /v1/payroll/runs: Each run
NameTypeWhat it is
iduuidThe run
periodstringYYYY-MM, the month its pay window ends in
pay_groupobjectid and name
statusstringlocked or released
headcountintegerPeople in the totals
gross_paiseintegerGross pay, in paise
deductions_paiseintegerDeductions and tax, in paise
net_paiseintegerNet pay, in paise
employer_contributions_paiseintegerEmployer cost on top of gross, in paise
currencystringAlways INR
locked_atinstant or nullWhen it was locked, UTC
released_atinstant or nullWhen 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
NameTypeWhat it is
iduuidThe run's id, in the path
limitinteger1 to 200. Default 50
cursorstringnext_cursor of the last page; lasts 24 hours

Each payslip

GET /v1/payroll/runs/{id}/payslips: Each payslip
NameTypeWhat it is
employee_iduuidThe person
gross_paiseintegerGross pay, in paise
deductions_paiseintegerDeductions and tax, in paise
net_paiseintegerNet pay, in paise
paid_daysnumberDays paid
lop_daysnumberLoss of pay days
currencystringAlways 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
EventNeedsWhen
employee.joinedpeople.readA person's record becomes active. Data: employee_id, date_of_joining.
employee.updatedpeople.readName, code, designation, department, location or manager changed, effective today or earlier. Data: employee_id, changed.
employee.exit_scheduledpeople.readAn exit is approved with a last working day. Data: employee_id, last_working_day.
employee.leftpeople.readThe record moves to exited. Data: employee_id, last_working_day.
leave.requestedleave.readA leave request is sent. Data: leave_request_id, employee_id, from, to.
leave.approvedleave.readA request is approved. Data: leave_request_id, employee_id, from, to.
leave.cancelledleave.readAn approved or pending request is cancelled or withdrawn. Data: leave_request_id, employee_id, from, to.
attendance.day_finalizedattendance.readA person's day is finalised, or finalised again after a correction. Data: employee_id, date, status.
payroll.run_lockedpayroll.read whole-company keyA pay run is locked. Data: payroll_run_id, period, pay_group_id.
payroll.payslips_releasedpayroll.read whole-company keyA run's payslips are released to people. Data: payroll_run_id, period, released_count.
webhook.testany keySomeone presses Send test in Settings.

Every event

Fields of every webhook event
NameTypeWhat it is
iduuidEquals the webhook-id header and stays the same on every retry: deduplicate on it
typestringThe event
created_atstringWhen the change happened, UTC
company_iduuidYour company's id, so one receiver can serve several companies
sequenceintegerGrows with each change to the same record: drop an older one that arrives after a newer one
dataobjectIds, 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
PartValue
Secretwhsec_TEVLSEFIUi1FWEFNUExFLVdFQkhPT0stU0VDUkVUISE=
webhook-id0b7c6f0e-5b4e-5c39-9a51-3c1d2f9e8a10
webhook-timestamp1793334600
Signed{webhook-id}.{webhook-timestamp}.{raw body}, HMAC-SHA256 with the bytes after whsec_, base64-decoded
webhook-signaturev1,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
DateChange
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.