The KalHR API is JSON over HTTPS. Every answer has the same shape, so one small helper in your code can handle all of them.
The address
Every request goes to:
https://api.kalhr.com/
followed by the endpoint's path, for example https://api.kalhr.com/v1/employee/list. The v1 in the path is the
version of that endpoint.
The envelope
Every answer, successful or not, is an object with four fields:
{
"isSuccessful": true,
"code": "00",
"message": "Success",
"data": { }
}
| Field | Meaning |
|---|---|
isSuccessful | true when the request did what you asked. Always check this. |
code | "00" when it worked. When it didn't: "ValidationError" if a value you sent failed a check, otherwise "01" |
message | When it didn't work, why, in a sentence you can log or show |
data | The result: one record, a list, or a page of results |
A request that KalHR understood but refused for a business reason, such as an id that isn't in your organisation,
answers HTTP 200 with isSuccessful: false. Checking only the status code would treat it as success. See
Errors.
A small helper that does both checks:
async function kalhr(path) {
const response = await fetch(`https://api.kalhr.com/${path}`, {
headers: { Authorization: `Bearer ${process.env.KALHR_API_KEY}` },
});
const result = await response.json().catch(() => null);
if (!response.ok || !result?.isSuccessful) {
throw new Error(`${response.status}: ${result?.message ?? response.statusText}`);
}
return result.data;
}
Fields and values
Missing means empty. A field with no value is left out of the answer rather than sent as null. Treat a missing
field as empty, and don't fail on fields you don't recognise: new ones are added over time.
Ids are UUIDs, such as 4b9d2c1e-7a3f-4e8b-9c21-5d6e7f8a9b0c. Use the id of a record to fetch it or to filter
by it. A few records also carry a number people use (an employee's employeeId is their employee number, a payroll's
payrollCode its code); those are for display and matching, not for calling the API.
Choices (a status, a work mode) come either as a number or as a name, and each reference page says which. Where a
field is a number, the page lists what each number means. Filters accept the number or the name: LeaveStatus=2 and
LeaveStatus=Approved are the same.
Money is a JSON number in naira, such as 650000 or 512340.5. Don't round it before you store it.
Dates and times
| Kind | Looks like | Read it as |
|---|---|---|
| A calendar date (a leave day, a pay day, a date of birth) | 2026-11-02T00:00:00 | The date part only. The time means nothing. |
| A moment (when something was created or requested) | 2026-10-14T08:02:55.381 | UTC, even without a Z |
A moment with a Z | 2026-10-19T14:30:00Z | UTC |
All moments are UTC. Some are written without the Z; read a time with no offset as UTC, never as your local time.
When you send a date, as a filter, write it as YYYY-MM-DD: StartDate=2026-11-01.
Pages
Lists come back a page at a time, inside data:
{
"isSuccessful": true,
"code": "00",
"message": "Success",
"data": {
"data": [ { "id": "…" }, { "id": "…" } ],
"pageNumber": 2,
"pageSize": 50,
"totalRecords": 134,
"totalPages": 3
}
}
| Field | Meaning |
|---|---|
data | This page's records |
pageNumber | The page you asked for |
pageSize | Records per page |
totalRecords | Records across all pages |
totalPages | How many pages there are |
Ask for a page with PageNumber (from 1) and PageSize (25 when you don't say). Keep asking for the next page until
pageNumber reaches totalPages. A PageSize of 100 keeps the number of requests down without making each one slow.
GET /v1/employee/list?PageNumber=2&PageSize=50
Ignore a totalCount field if you see one; it is always 0. Use totalRecords.
Filters
Filters are query parameters. Their names are written as in the reference, with a capital first letter, though the API doesn't mind the case.
A filter that takes several ids repeats the parameter:
GET /v1/employee/list?Companies=1f2e3d4c-…&Companies=7a8b9c0d-…
Filters combine: Status=Active&DepartmentId=… returns active employees in that department.