Employees
The roster, each person's HR/statutory profile, their attached documents, their LHDN
statutory forms, and bulk import/export of the lot. Unless an endpoint says otherwise,
it needs the organization's plan to include Employees — a key in a
plan without it gets 403 with
This organization's plan does not include the 'employees' module.
Employees
Everyone in the organization, with their role and employment basics.
id on each row is the user id, not the payroll
profile id. Payroll endpoints (e.g. GET /payroll/employees on
payroll.html) key on employeeProfileId
instead — look it up there, or from GET /employees/{id}/profile below.
Response 200
| Field | Type | Meaning |
|---|---|---|
id | string | The user id (not the payroll profile id). |
email | string | Login email. |
name | string | Display name. |
avatarUrl | string | null | Profile picture, if set. |
role | string | Employee, Supervisor, Admin or Owner — in this org. |
employeeNumber | string | null | Staff/payroll number in this org. |
jobTitle | string | null | |
joinDate | string (date) | null | First day of work in this org. |
otTimeBalanceMin | number | Banked overtime time-off, in minutes. |
policyId | string | null | Leave/attendance policy, if not the org default. |
shiftId | string | null | Shift, if not the project default. |
modules | string[] | null | Per-admin module grant. null = full access; only meaningful when role is Admin. |
curl https://<api-host>/employees \
-H "Authorization: Bearer wp_live_xxx"
Add a member to the key's own organization. If the email already belongs to a user (someone who works at another company on AltomateHR too), that identity is reused — a second membership is added to it — rather than a new account created.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Max 120 chars. |
password | string | Only for a new account | Max 100 chars. Ignored when the email already has an account — that person keeps their password. |
sendWelcomeEmail | boolean | No | Default false. |
dateOfBirth | string (date) | Only for a new account if password is omitted | The first password is then the email followed by the birthday as MMDD. |
name | string | Only for a new account | Max 160 chars. Ignored when reusing an existing identity. |
employeeNumber | string | Yes | Max 40 chars. Payroll's statutory files key on it. |
jobTitle | string | No | Max 120 chars. |
joinDate | string (date) | No | Drives pro-rated leave accrual. |
role | string | Yes | One of Employee, Supervisor, Admin, Owner (case-insensitive). Defaults to Employee if omitted. |
policyId | string | No | Max 40 chars. |
shiftId | string | No | Max 40 chars. |
modules | string[] | null | No | Per-admin module grant (only meaningful for an Admin). null = full access, [] = locked out, otherwise a subset of the plan's modules. |
Response 200
The new (or reused) member, in the same shape as a row from GET /employees.
sendWelcomeEmail: true does not tell you whether the email
actually went out — the service tracks that, but the controller only returns the
employee object. The person is added either way.
Errors
| Status | When | Body |
|---|---|---|
400 | Bad role, blank email, already a member, or a new account missing a required field | { "error": "…" } |
curl -X POST https://<api-host>/employees \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"name": "Jane Example",
"dateOfBirth": "1992-04-10",
"employeeNumber": "EMP-042",
"role": "Employee"
}'
Change a member's role, supervisor-layer placement, or roster basics. {id} is the user id.
null on
name, email, employeeNumber, jobTitle
or joinDate means "leave unchanged" — omit a field rather than send
null to clear it. employeeNumber/jobTitle accept
"" to clear them on purpose. joinDate needs
clearJoinDate: true to be cleared, since null there is
"unchanged". role, policyId/shiftId and
modules ARE replaced outright on every call.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | null | No | Max 160 chars. null = unchanged. |
email | string | null | No | Max 120 chars. null = unchanged; rejected if another user already has it. |
employeeNumber | string | null | No | Max 40 chars. null = unchanged, "" = clear. |
jobTitle | string | null | No | Max 120 chars. null = unchanged, "" = clear. |
joinDate | string (date) | null | No | null = unchanged. Recomputes pro-rated leave accrual when it actually changes. |
clearJoinDate | boolean | No | Default false. Wins over joinDate. |
role | string | Yes | One of Employee, Supervisor, Admin, Owner. Always replaced — resend the current value to leave it as is. |
policyId | string | null | No | Replaced outright; null falls back to the org default. |
shiftId | string | null | No | Replaced outright; null falls back to the project default. |
modules | string[] | null | No | Replaced outright. null = full access, [] = locked out. |
Response 200
The updated member, in the same shape as a row from GET /employees.
Errors
| Status | When | Body |
|---|---|---|
400 | Bad role, duplicate email, or demoting someone who still has people routing approvals to them | { "error": "…" } |
404 | {id} is not a member of this organization | empty |
curl -X PUT https://<api-host>/employees/usr_123 \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "role": "Supervisor", "jobTitle": "Site Lead" }'
Profile
The full HR/statutory profile behind each member — personal details, tax reliefs, statutory numbers, bank details and salary. This is where an admin screen or an integration edits the record payroll actually reads.
{id} is the user id (as returned by GET /employees).
Response 200
If no profile has ever been saved for this person, the response is a shell with
only id/employeeProfileId (null)/email/name
populated and every other field at its default — so a form can still load.
| Field | Type | Meaning |
|---|---|---|
id | string | The user id. Read-only. |
employeeProfileId | string | null | The profile row's own id — what payroll keys on (salary history, payslips). null until the profile is first saved. Read-only. |
email | string | Read-only (edit via PUT /employees/{id}). |
name | string | Read-only (edit via PUT /employees/{id}). |
Every other field below is editable — see PUT /employees/{id}/profile.
Create (on first save) or replace this person's profile. {id} is the user id.
GET the profile first, change what you need, and send the whole object
back; any field you omit is written as its empty/default value. Saving also marks
every draft payroll run stale (it is regenerated, not auto-updated).
Request
Context fields (id, employeeProfileId, email, name) are ignored if sent — they come from the route and the user record. Everything else:
Personal / demographic
| Field | Type | Notes |
|---|---|---|
phone | string | null | |
alternateEmail | string | null | Not used to sign in. |
gender | string | null | MALE or FEMALE. |
dateOfBirth | string (date) | null | |
nationality | string | null | Normalised against the dropdown's spelling (e.g. "Malaysia" → "Malaysian"). |
race | string | null | |
hasPr | boolean | Malaysian PR. Default false. |
idType | string | null | NRIC, PASSPORT, ARMY_NO or POLICE_NO. |
idNumber | string | null | IC or passport number. |
maritalStatus | string | null | SINGLE, MARRIED, DIVORCED or WIDOWED. |
isResident | boolean | Tax resident. Default true. |
isOku | boolean | Person with disability. Default false. |
addressLine1 / addressLine2 / city / postcode / state | string | null | Home address. |
emergencyContactName / emergencyContactPhone / emergencyContactRelation | string | null |
Employment placement
| Field | Type | Notes |
|---|---|---|
joinDate | string (date) | null | First day of work in this org. |
leaveDate | string (date) | null | Last working day. Gates several LHDN forms — see below. |
department / location / workSchedule | string | null | Free text. |
employmentStatus | string | null | MANAGEMENT, PERMANENT, CONTRACT, PART_TIME, INDUSTRIAL_TRAINEE or OTHER. Reported on CP8D. |
contractEndDate | string (date) | null |
Spouse / tax relief
| Field | Type | Notes |
|---|---|---|
spouseWorking | boolean | null | |
spouseDisabled | boolean | null | |
spousePcbNumber | string | null | Spouse's income tax number. |
spouseIdNumber | string | null | |
childReliefJson | string | null | JSON array of children — see below. |
childReliefJson is a JSON array string, one object per
child:
[
{
"abilityStatus": "NORMAL",
"currentlyStudying": "UNDER_18",
"pcbDeduction": "NONE"
}
]
abilityStatus:NORMALorDISABLED.currentlyStudying:UNDER_18,PRE_UNIVERSITY,DIPLOMA_MALAYSIAorDEGREE_ABROAD— the LHDN relief band the child's education falls into.pcbDeduction:FULL,HALF(split with the other parent) orNONE(default — add a child without claiming them yet).
Prior-employment year-to-date (for tax)
| Field | Type | Notes |
|---|---|---|
prevEmploymentYear | number | null | |
prevRemuneration | number | null | Money. |
prevEpf | number | null | Money. |
prevAllowableDeductions | number | null | Money. |
prevPcb | number | null | Money. |
prevZakat | number | null | Money. |
prevIncludesPriorThisOrgPeriod | boolean | Default false. |
prevByCategoryJson | string | null | JSON — the previous employer's figures by payroll adjustment category, for yearly exemption/relief limits. Shape is owned by the payroll module. |
EPF
| Field | Type | Notes |
|---|---|---|
contributeToEpf | boolean | Default true. Can't be blank on import. |
epfNumber | string | null | |
epfEmployeeRate | number | A percentage; 0 = use the statutory rate. |
epfEmployeeVoluntary | number | A percentage, on top of the statutory rate. |
epfEmployerVoluntary | number | A percentage, on top of the statutory rate. |
epfMemberBefore1998 | boolean | Default false. Keeps a non-resident, non-PR member on the standard EPF rate instead of Part F. |
SOCSO / EIS / SKBBK
| Field | Type | Notes |
|---|---|---|
socsoNumber | string | null | |
socsoScheme | string | null | EMPLOYMENT_INJURY_INVALIDITY or EMPLOYMENT_INJURY_ONLY. |
contributeToEis | boolean | Default true. Can't be blank on import. |
contributeToSkbbk | boolean | Default false. |
Income tax
| Field | Type | Notes |
|---|---|---|
specialTaxScheme | string | null | RETURNING_EXPERT, KNOWLEDGE_WORKER or C_SUITE — the three 15% flat-tax approvals. |
specialTaxFrom / specialTaxTo | string (date) | null | Stored as the first of the month; ignored unless specialTaxScheme is set. |
incomeTaxNumber | string | null | |
pcbBorneByEmployer | boolean | Default false. |
ssfwNumber | string | null |
Bank / payment
| Field | Type | Notes |
|---|---|---|
paymentMethod | string | BANK_TRANSFER (default), CASH or CHEQUE. |
bankName | string | null | Matched against a bank-name lookup when payroll builds a bank file; an unrecognised name blocks that file, not this save. |
bankAccountHolderName / bankAccountNumber | string | null |
Salary & salary-change trail
| Field | Type | Notes |
|---|---|---|
salaryType | string | MONTHLY (default) or HOURLY. |
monthlySalary | number | null | Money. |
hourlyRate | number | null | Money. |
fixedAllowancesJson | string | null | JSON array of recurring per-run allowances — see below. |
salaryChangeEffectiveDate | string (date) | null | When a salary change took effect. Can't be in the future (v2 applies a change immediately) — a future date is rejected with 400. null = today. |
salaryChangeReason | string | null | RAISE (default if omitted), PROMOTION, DEMOTION, RESTRUCTURE or OTHER. |
salaryChangeNotes | string | null | |
salaryChangeIsCorrection | boolean | Default false. true fixes a typo without writing a salary-history row — use this for data cleanup, and leave it false for a real raise/promotion/demotion. |
salaryChangeIsCorrection: true) and not for a first
salary (nothing, or 0, was set before). Sending the salary-change fields
on an edit that doesn't touch salary is harmless; they're simply unused.
fixedAllowancesJson is a JSON array string, one object
per recurring line:
[
{
"category": "allowance_standard",
"name": "Transport",
"amount": 300.00,
"treatAsRecurring": false
}
]
category: a payroll adjustment category code (free-form string, not an enum — an unrecognised one is skipped rather than failing the save).name: the admin's own label; falls back to the category's label when omitted.amount: money, per pay run.treatAsRecurring: only meaningful on an additional-remuneration category — routes it through the smoothed monthly PCB path instead of being taxed as a one-off bonus. Never affects EPF.
Payroll config
| Field | Type | Notes |
|---|---|---|
payrollPolicy | string | null | |
payrollCycle | string | null | |
leaveEntitlementJson | string | null | JSON — shape owned by the leave/payroll modules. |
payrollDocumentsJson | string | null | Internal document references; not the same list as GET /employees/{id}/documents. |
Lifecycle (archiving)
| Field | Type | Notes |
|---|---|---|
isArchived | boolean | Default false. See below. |
archivedAt | string (date-time) | Ignored on write — stamped by the server the moment isArchived flips to true, and cleared when it flips back. Read-only in practice. |
archiveReason | string | null | Kept only while isArchived is true; saving with isArchived: false clears it. |
temporaryReviewDate | string (date) | null |
PUT, with isArchived flipped and the rest of the profile
unchanged (so GET first). Archiving affects which LHDN forms are
available for the person (see below) and which payroll-import template columns
apply to them.
Response
200 with the full profile (same shape as GET) on success.
Errors
| Status | When | Body |
|---|---|---|
400 | salaryChangeEffectiveDate is in the future (and it's not a correction) | { "error": "…" } |
404 | {id} is not a member of this organization | empty |
curl -X PUT https://<api-host>/employees/usr_123/profile \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"idNumber": "900101-14-5567",
"idType": "NRIC",
"maritalStatus": "MARRIED",
"salaryType": "MONTHLY",
"monthlySalary": 5000.00,
"contributeToEpf": true,
"contributeToEis": true,
"paymentMethod": "BANK_TRANSFER"
}'
Documents
Files attached to a profile — ID scans, contracts, certificates. {id} is always the user id.
Response 200
| Field | Type | Meaning |
|---|---|---|
id | string | Use with the download/delete endpoints below. |
name | string | Original file name. |
mimeType | string | |
sizeBytes | number | |
uploadedAt | string (date-time) |
404 (empty) when {id} is not a member of this organization.
multipart/form-data with one field, file. Up to 10 MB.
Response 200
The new document, in the same shape as a GET /employees/{id}/documents row.
Errors
| Status | When | Body |
|---|---|---|
400 | No file sent, or the service rejects it | { "error": "…" } |
404 | {id} is not a member of this organization | empty |
curl -X POST https://<api-host>/employees/usr_123/documents \
-H "Authorization: Bearer wp_live_xxx" \
-F "file=@contract.pdf"
Streams the file bytes with its original content type. 404 (empty) if either id doesn't resolve.
204 on success. 404 (empty) if either id doesn't resolve.
LHDN forms
The five per-employee statutory PDFs (CP22, CP22A, CP21, TP3, PCB 2(II)) — summaries
HR transcribes onto the official LHDN form or pastes into e-PCB. {id} is
the user id.
Which of the 5 forms can be generated right now, and why not when they can't.
Response 200
| Field | Type | Meaning |
|---|---|---|
kind | string | PCB2II, CP22, CP22A, CP21 or TP3 — use with the download endpoint below. |
code | string | e.g. "CP22", "PCB 2(II)", "PCB/TP3". |
title | string | e.g. "New-employee notification". |
description | string | |
needsYearPicker | boolean | true only for PCB2II. |
enabled | boolean | Whether the form can be generated for this person right now. |
disabledReason | string | null | Set when enabled is false: "Available once a leave date is set" (CP22A/CP21/TP3) or "Available only for active employees" (CP22). |
badge | string | null | CP22/CP22A/CP21 only, e.g. "Due in 12 days" or "Overdue by 3 days". |
badgeVariant | string | null | One of success, pending, rejected, outline. |
404 (empty) when {id} is not a member of this organization.
{
"kind": "CP22",
"code": "CP22",
"title": "New-employee notification",
"description": "Notification to LHDN that a new employee has joined.",
"needsYearPicker": false,
"enabled": true,
"disabledReason": null,
"badge": "Due in 18 days",
"badgeVariant": "success"
}
Request
| Param | Type | Required | Notes |
|---|---|---|---|
kind | string (route) | Yes | One of PCB2II, CP22, CP22A, CP21, TP3 (case-insensitive). |
year | number (query) | No | PCB2II only; defaults to the current year. |
Response 200
application/pdf bytes.
Errors
| Status | When | Body |
|---|---|---|
400 | Unknown kind, or the form isn't available yet for this person (e.g. no leave date set) | { "error": "…" } |
404 | {id} is not a member of this organization | empty |
curl https://<api-host>/employees/usr_123/lhdn-forms/CP22/download \
-H "Authorization: Bearer wp_live_xxx" \
-o CP22_EMP-042.pdf
Importing & exporting
One spreadsheet covers the whole roster — adding people and bulk-updating their
membership, profile, statutory and salary fields in one pass. email
identifies a row and is never changed by an import.
Request
| Param | Type | Required | Notes |
|---|---|---|---|
format | string (query) | No | xlsx (default) or csv. |
Response 200
The file bytes, as xlsx or csv content type (see
Importing & exporting below for the exact MIME types). The
XLSX template includes a READ ME sheet and a Columns guide
alongside the empty Employees sheet; a key without Payroll access in its
org gets a template without the statutory/salary/bank columns.
curl https://<api-host>/employees/import/template?format=xlsx \
-H "Authorization: Bearer wp_live_xxx" \
-o employees-import-template.xlsx
The whole roster, in the import's column layout — edit it and re-import to bulk-update.
Request
| Param | Type | Required | Notes |
|---|---|---|---|
format | string (query) | No | xlsx (default) or csv. |
fields | string (query, comma-separated) | No | Export only these column keys, as a plain read-only table instead of the re-importable layout. See GET /employees/export/fields for the keys. |
includeArchived | boolean (query) | No | Default true. Only applies with fields set. |
Response 200
File bytes, same content type as the template:
| Format | Content type |
|---|---|
xlsx | application/vnd.openxmlformats-officedocument.spreadsheetml.sheet |
csv | text/csv |
400 { "error": "Choose at least one field to export." } when fields is set but none of the keys are recognised or allowed.
curl "https://<api-host>/employees/export?format=csv" \
-H "Authorization: Bearer wp_live_xxx" \
-o employees.csv
The column keys GET /employees/export?fields=… accepts, grouped for a picker UI.
Response 200
| Field | Type | Meaning |
|---|---|---|
key | string | Pass this in fields, e.g. idNumber, monthlySalary. |
label | string | e.g. "IC / Passport No". |
group | string | One of Employee, Personal, Contact & address, Emergency contact, Employment, Spouse, Salary, Statutory, Payment. |
The response is a flat array (not grouped); statutory/salary/bank keys are omitted for a key without Payroll access in its org.
multipart/form-data. Field file: a .csv or .xlsx export/template, detected by extension.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
file | file | Yes | Multipart field name is file. |
blankCells | string (form field) | No | Keep (default) or Erase — what a blank cell does to an existing person's field; a column missing from the file is left alone either way. |
Key columns (by header text or listed alias)
| Column | Required for a new person | Notes |
|---|---|---|
| Always | Identifies the row; never changed by an import. | |
| Name | Yes | Can't be erased on an existing person either. |
| Role | Defaults to Employee | Can't be erased. |
| Employee No | Yes | Can't be erased. |
| Date of Birth | Yes | First password = email + birthday as MMDD. |
| Job Title, Join Date, Policy, Shift | No | Policy/Shift are matched by name, not id. |
| Salary Type, Contribute to EPF, Contribute to EIS | Default applies | Can't be erased (reset to their default instead: MONTHLY / Yes / Yes). |
Every other profile column from PUT /employees/{id}/profile has a
matching sheet column (see GET /employees/export/fields for the full,
grouped list) — dates as YYYY-MM-DD, booleans as Yes/No,
percentages as plain numbers, money tolerant of formatting like "RM 5,000.00".
Statutory/salary/bank columns are only in the file for a key whose org grants Payroll
access; present in an uploaded file without that access, they're ignored (not an error).
errors with its row number, while every
other valid row is still created or updated. The response is still 200
in that case — check errors, don't rely on the status code.
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true only when errors is empty. |
created | number | Rows that added a new person. |
updated | number | Rows that updated an existing person. |
errors | array of { row, message } | row is the 1-based row number as seen in the sheet (header is row 1). That row was NOT applied. |
warnings | array of { row, message } | Applied, but worth a look — e.g. an unrecognised nationality kept as typed, or payroll columns ignored. |
createdAccounts | array of { email, name, password } | The brand-new login accounts this import created, with the password each was given. Returned once — store it now, it is not recoverable afterwards. Absent for an existing person and for an email that already had an account in another company. |
Errors
| Status | When | Body |
|---|---|---|
400 | No file sent, the file can't be read, it's empty, or it's missing a required column (a whole-file problem, not a row problem) | { "error": "…" } |
curl -X POST https://<api-host>/employees/import \
-H "Authorization: Bearer wp_live_xxx" \
-F "file=@employees.xlsx" \
-F "blankCells=Keep"
{
"ok": false,
"created": 1,
"updated": 4,
"errors": [
{
"row": 7,
"message": "\"missing@example.com\" is new, so Employee No is required."
}
],
"warnings": [],
"createdAccounts": [
{
"email": "new.hire@example.com",
"name": "New Hire",
"password": "new.hire@example.com1123"
}
]
}
Counts & pending
Two small orientation endpoints. Unlike everything above, neither lives on
EmployeesController and neither is gated by the Employees module — they
answer across whatever the key's scopes allow.
One number — seats for billing — rather than a list. Counts memberships, not users: the same person in two of your organizations counts once per organization.
Response 200
| Field | Type | Meaning |
|---|---|---|
count | number | |
asOf | string (date-time) | ISO 8601 round-trip timestamp of the count. |
{ "count": 42, "asOf": "2026-01-15T03:21:09.4130000Z" }
curl https://<api-host>/employees/active-count \
-H "Authorization: Bearer wp_live_xxx"
What's waiting for a decision, across claims and leave (not employees specifically —
there's nothing "pending" on a roster row). A key only sees the sections its scopes
allow; the rest are named in omitted rather than reported as zero, so an
empty queue and "you may not ask" are never confused.
Response 200
| Field | Type | Meaning |
|---|---|---|
claims | number | Pending claims. Present only with claims:read. |
leave | number | Pending leave requests. Present only with leave:read. |
omitted | string[] | Which of "claims" / "leave" were left out for missing scope. |
{ "claims": 3, "omitted": ["leave"] }
curl https://<api-host>/pending \
-H "Authorization: Bearer wp_live_xxx"
Not available to API keys
POST /employees/{userId}/password is marked [HumanOnly] — a key can
never call it. Overwriting a member's login password is for a signed-in Admin/Owner only,
since a key that could set a password could take the account over.