Developer docs
Employees
REST · JSON · API keys

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

GET /employees employees:read

Everyone in the organization, with their role and employment basics.

The 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

FieldTypeMeaning
idstringThe user id (not the payroll profile id).
emailstringLogin email.
namestringDisplay name.
avatarUrlstring | nullProfile picture, if set.
rolestringEmployee, Supervisor, Admin or Owner — in this org.
employeeNumberstring | nullStaff/payroll number in this org.
jobTitlestring | null
joinDatestring (date) | nullFirst day of work in this org.
otTimeBalanceMinnumberBanked overtime time-off, in minutes.
policyIdstring | nullLeave/attendance policy, if not the org default.
shiftIdstring | nullShift, if not the project default.
modulesstring[] | nullPer-admin module grant. null = full access; only meaningful when role is Admin.
curl https://<api-host>/employees \
  -H "Authorization: Bearer wp_live_xxx"
POST /employees employees:write

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

FieldTypeRequiredNotes
emailstringYesMax 120 chars.
passwordstringOnly for a new accountMax 100 chars. Ignored when the email already has an account — that person keeps their password.
sendWelcomeEmailbooleanNoDefault false.
dateOfBirthstring (date)Only for a new account if password is omittedThe first password is then the email followed by the birthday as MMDD.
namestringOnly for a new accountMax 160 chars. Ignored when reusing an existing identity.
employeeNumberstringYesMax 40 chars. Payroll's statutory files key on it.
jobTitlestringNoMax 120 chars.
joinDatestring (date)NoDrives pro-rated leave accrual.
rolestringYesOne of Employee, Supervisor, Admin, Owner (case-insensitive). Defaults to Employee if omitted.
policyIdstringNoMax 40 chars.
shiftIdstringNoMax 40 chars.
modulesstring[] | nullNoPer-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.

Sending 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

StatusWhenBody
400Bad 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"
  }'
PUT /employees/{id} employees:write

Change a member's role, supervisor-layer placement, or roster basics. {id} is the user id.

Patch semantics, not replace: 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

FieldTypeRequiredNotes
namestring | nullNoMax 160 chars. null = unchanged.
emailstring | nullNoMax 120 chars. null = unchanged; rejected if another user already has it.
employeeNumberstring | nullNoMax 40 chars. null = unchanged, "" = clear.
jobTitlestring | nullNoMax 120 chars. null = unchanged, "" = clear.
joinDatestring (date) | nullNonull = unchanged. Recomputes pro-rated leave accrual when it actually changes.
clearJoinDatebooleanNoDefault false. Wins over joinDate.
rolestringYesOne of Employee, Supervisor, Admin, Owner. Always replaced — resend the current value to leave it as is.
policyIdstring | nullNoReplaced outright; null falls back to the org default.
shiftIdstring | nullNoReplaced outright; null falls back to the project default.
modulesstring[] | nullNoReplaced outright. null = full access, [] = locked out.

Response 200

The updated member, in the same shape as a row from GET /employees.

Errors

StatusWhenBody
400Bad role, duplicate email, or demoting someone who still has people routing approvals to them{ "error": "…" }
404{id} is not a member of this organizationempty
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.

GET /employees/{id}/profile employees:read

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

FieldTypeMeaning
idstringThe user id. Read-only.
employeeProfileIdstring | nullThe profile row's own id — what payroll keys on (salary history, payslips). null until the profile is first saved. Read-only.
emailstringRead-only (edit via PUT /employees/{id}).
namestringRead-only (edit via PUT /employees/{id}).

Every other field below is editable — see PUT /employees/{id}/profile.

PUT /employees/{id}/profile employees:write

Create (on first save) or replace this person's profile. {id} is the user id.

This PUT replaces every editable field — it is not a patch. 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

FieldTypeNotes
phonestring | null
alternateEmailstring | nullNot used to sign in.
genderstring | nullMALE or FEMALE.
dateOfBirthstring (date) | null
nationalitystring | nullNormalised against the dropdown's spelling (e.g. "Malaysia" → "Malaysian").
racestring | null
hasPrbooleanMalaysian PR. Default false.
idTypestring | nullNRIC, PASSPORT, ARMY_NO or POLICE_NO.
idNumberstring | nullIC or passport number.
maritalStatusstring | nullSINGLE, MARRIED, DIVORCED or WIDOWED.
isResidentbooleanTax resident. Default true.
isOkubooleanPerson with disability. Default false.
addressLine1 / addressLine2 / city / postcode / statestring | nullHome address.
emergencyContactName / emergencyContactPhone / emergencyContactRelationstring | null

Employment placement

FieldTypeNotes
joinDatestring (date) | nullFirst day of work in this org.
leaveDatestring (date) | nullLast working day. Gates several LHDN forms — see below.
department / location / workSchedulestring | nullFree text.
employmentStatusstring | nullMANAGEMENT, PERMANENT, CONTRACT, PART_TIME, INDUSTRIAL_TRAINEE or OTHER. Reported on CP8D.
contractEndDatestring (date) | null

Spouse / tax relief

FieldTypeNotes
spouseWorkingboolean | null
spouseDisabledboolean | null
spousePcbNumberstring | nullSpouse's income tax number.
spouseIdNumberstring | null
childReliefJsonstring | nullJSON array of children — see below.
childReliefJson is a JSON array string, one object per child:
[
  {
    "abilityStatus": "NORMAL",
    "currentlyStudying": "UNDER_18",
    "pcbDeduction": "NONE"
  }
]
  • abilityStatus: NORMAL or DISABLED.
  • currentlyStudying: UNDER_18, PRE_UNIVERSITY, DIPLOMA_MALAYSIA or DEGREE_ABROAD — the LHDN relief band the child's education falls into.
  • pcbDeduction: FULL, HALF (split with the other parent) or NONE (default — add a child without claiming them yet).
Read back leniently: an unparseable string, a non-array, or an unknown/legacy value reads as no relief for that field rather than an error, so send exactly these values to be sure what's stored.

Prior-employment year-to-date (for tax)

FieldTypeNotes
prevEmploymentYearnumber | null
prevRemunerationnumber | nullMoney.
prevEpfnumber | nullMoney.
prevAllowableDeductionsnumber | nullMoney.
prevPcbnumber | nullMoney.
prevZakatnumber | nullMoney.
prevIncludesPriorThisOrgPeriodbooleanDefault false.
prevByCategoryJsonstring | nullJSON — the previous employer's figures by payroll adjustment category, for yearly exemption/relief limits. Shape is owned by the payroll module.

EPF

FieldTypeNotes
contributeToEpfbooleanDefault true. Can't be blank on import.
epfNumberstring | null
epfEmployeeRatenumberA percentage; 0 = use the statutory rate.
epfEmployeeVoluntarynumberA percentage, on top of the statutory rate.
epfEmployerVoluntarynumberA percentage, on top of the statutory rate.
epfMemberBefore1998booleanDefault false. Keeps a non-resident, non-PR member on the standard EPF rate instead of Part F.

SOCSO / EIS / SKBBK

FieldTypeNotes
socsoNumberstring | null
socsoSchemestring | nullEMPLOYMENT_INJURY_INVALIDITY or EMPLOYMENT_INJURY_ONLY.
contributeToEisbooleanDefault true. Can't be blank on import.
contributeToSkbbkbooleanDefault false.

Income tax

FieldTypeNotes
specialTaxSchemestring | nullRETURNING_EXPERT, KNOWLEDGE_WORKER or C_SUITE — the three 15% flat-tax approvals.
specialTaxFrom / specialTaxTostring (date) | nullStored as the first of the month; ignored unless specialTaxScheme is set.
incomeTaxNumberstring | null
pcbBorneByEmployerbooleanDefault false.
ssfwNumberstring | null

Bank / payment

FieldTypeNotes
paymentMethodstringBANK_TRANSFER (default), CASH or CHEQUE.
bankNamestring | nullMatched against a bank-name lookup when payroll builds a bank file; an unrecognised name blocks that file, not this save.
bankAccountHolderName / bankAccountNumberstring | null

Salary & salary-change trail

FieldTypeNotes
salaryTypestringMONTHLY (default) or HOURLY.
monthlySalarynumber | nullMoney.
hourlyRatenumber | nullMoney.
fixedAllowancesJsonstring | nullJSON array of recurring per-run allowances — see below.
salaryChangeEffectiveDatestring (date) | nullWhen 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.
salaryChangeReasonstring | nullRAISE (default if omitted), PROMOTION, DEMOTION, RESTRUCTURE or OTHER.
salaryChangeNotesstring | null
salaryChangeIsCorrectionbooleanDefault 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.
A history row is written only when the salary actually changed — not for a correction (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

FieldTypeNotes
payrollPolicystring | null
payrollCyclestring | null
leaveEntitlementJsonstring | nullJSON — shape owned by the leave/payroll modules.
payrollDocumentsJsonstring | nullInternal document references; not the same list as GET /employees/{id}/documents.

Lifecycle (archiving)

FieldTypeNotes
isArchivedbooleanDefault false. See below.
archivedAtstring (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.
archiveReasonstring | nullKept only while isArchived is true; saving with isArchived: false clears it.
temporaryReviewDatestring (date) | null
There is no separate archive endpoint — archiving and restoring a member is the same 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

StatusWhenBody
400salaryChangeEffectiveDate is in the future (and it's not a correction){ "error": "…" }
404{id} is not a member of this organizationempty
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.

GET /employees/{id}/documents employees:read

Response 200

FieldTypeMeaning
idstringUse with the download/delete endpoints below.
namestringOriginal file name.
mimeTypestring
sizeBytesnumber
uploadedAtstring (date-time)

404 (empty) when {id} is not a member of this organization.

POST /employees/{id}/documents employees:write

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

StatusWhenBody
400No file sent, or the service rejects it{ "error": "…" }
404{id} is not a member of this organizationempty
curl -X POST https://<api-host>/employees/usr_123/documents \
  -H "Authorization: Bearer wp_live_xxx" \
  -F "file=@contract.pdf"
GET /employees/{id}/documents/{documentId}/download employees:read

Streams the file bytes with its original content type. 404 (empty) if either id doesn't resolve.

DELETE /employees/{id}/documents/{documentId} employees:write

204 on success. 404 (empty) if either id doesn't resolve.

This removes the document from the list; the underlying file is deliberately left on disk rather than deleted.

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.

GET /employees/{id}/lhdn-forms employees:read

Which of the 5 forms can be generated right now, and why not when they can't.

Response 200

FieldTypeMeaning
kindstringPCB2II, CP22, CP22A, CP21 or TP3 — use with the download endpoint below.
codestringe.g. "CP22", "PCB 2(II)", "PCB/TP3".
titlestringe.g. "New-employee notification".
descriptionstring
needsYearPickerbooleantrue only for PCB2II.
enabledbooleanWhether the form can be generated for this person right now.
disabledReasonstring | nullSet when enabled is false: "Available once a leave date is set" (CP22A/CP21/TP3) or "Available only for active employees" (CP22).
badgestring | nullCP22/CP22A/CP21 only, e.g. "Due in 12 days" or "Overdue by 3 days".
badgeVariantstring | nullOne 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"
}
GET /employees/{id}/lhdn-forms/{kind}/download employees:read

Request

ParamTypeRequiredNotes
kindstring (route)YesOne of PCB2II, CP22, CP22A, CP21, TP3 (case-insensitive).
yearnumber (query)NoPCB2II only; defaults to the current year.

Response 200

application/pdf bytes.

Errors

StatusWhenBody
400Unknown 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 organizationempty
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.

GET /employees/import/template employees:read

Request

ParamTypeRequiredNotes
formatstring (query)Noxlsx (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
GET /employees/export employees:read

The whole roster, in the import's column layout — edit it and re-import to bulk-update.

Request

ParamTypeRequiredNotes
formatstring (query)Noxlsx (default) or csv.
fieldsstring (query, comma-separated)NoExport only these column keys, as a plain read-only table instead of the re-importable layout. See GET /employees/export/fields for the keys.
includeArchivedboolean (query)NoDefault true. Only applies with fields set.

Response 200

File bytes, same content type as the template:

FormatContent type
xlsxapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheet
csvtext/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
GET /employees/export/fields employees:read

The column keys GET /employees/export?fields=… accepts, grouped for a picker UI.

Response 200

FieldTypeMeaning
keystringPass this in fields, e.g. idNumber, monthlySalary.
labelstringe.g. "IC / Passport No".
groupstringOne 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.

POST /employees/import employees:write

multipart/form-data. Field file: a .csv or .xlsx export/template, detected by extension.

Request

FieldTypeRequiredNotes
filefileYesMultipart field name is file.
blankCellsstring (form field)NoKeep (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)

ColumnRequired for a new personNotes
EmailAlwaysIdentifies the row; never changed by an import.
NameYesCan't be erased on an existing person either.
RoleDefaults to EmployeeCan't be erased.
Employee NoYesCan't be erased.
Date of BirthYesFirst password = email + birthday as MMDD.
Job Title, Join Date, Policy, ShiftNoPolicy/Shift are matched by name, not id.
Salary Type, Contribute to EPF, Contribute to EISDefault appliesCan'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).

Not all-or-nothing. Each row is applied independently: a row with a problem is skipped and listed in 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

FieldTypeMeaning
okbooleantrue only when errors is empty.
creatednumberRows that added a new person.
updatednumberRows that updated an existing person.
errorsarray of { row, message }row is the 1-based row number as seen in the sheet (header is row 1). That row was NOT applied.
warningsarray of { row, message }Applied, but worth a look — e.g. an unrecognised nationality kept as typed, or payroll columns ignored.
createdAccountsarray 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

StatusWhenBody
400No 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.

GET /employees/active-count employees:read

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

FieldTypeMeaning
countnumber
asOfstring (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"
GET /pending No scope needed to call — each section needs its own scope

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

FieldTypeMeaning
claimsnumberPending claims. Present only with claims:read.
leavenumberPending leave requests. Present only with leave:read.
omittedstring[]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.