Payroll
Everything in payroll except running payroll itself: each employee's payroll details, their salary history, the pay-item catalogue, their payslips, staff loans, org-level settings and company info, the year-end LHDN forms, year-to-date history import, and saved statutory-portal logins. Creating, generating, submitting and approving a run — and its statutory files and documents — are on the Payroll runs page.
Every endpoint here needs the org's plan to include the Payroll module
(otherwise 403) and acts with Admin rights, which a key
always has. All money is MYR, two decimal places; all dates ISO 8601.
Payroll employees
The roster payroll sees: what each person is paid, which statutory numbers are on file, and where their money goes. Read-only here — every field is edited on the employee's own profile or through the bulk import.
employeeProfileId
(what every other payroll endpoint — loans, salary changes, adjustments — takes) AND
userId (what GET /employees calls id). They are
not interchangeable. A row with hasPayrollProfile: false is an org member
who has never had payroll details saved — its employeeProfileId is a
placeholder and must not be used to create a loan or record a salary change; fill in
their details first (via the import, or in the app).
The whole payroll roster for employees and supervisors (an Admin or Owner is never on payroll).
Request
| Query param | Type | Meaning |
|---|---|---|
includeArchived | boolean | Default false. Include archived employees. |
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
employeeProfileId | string | The id every other payroll endpoint takes. |
userId | string | The id /employees calls id. |
name, email | string | |
employeeNumber, department, jobTitle | string? | |
salaryType | string | "MONTHLY" or "HOURLY". |
monthlySalary, hourlyRate | decimal? | Only one applies, per salaryType. |
contributeToEpf, contributeToEis | boolean | |
epfNumber, epfEmployeeRate | string?, decimal | |
socsoNumber | string? | |
incomeTaxNumber | string? | Not required for a run to include someone — see Readiness below. |
paymentMethod | string | "BANK_TRANSFER", "CASH" or "CHEQUE". |
bankName, bankAccountNumber | string? | |
idNumber, idType | string? | idType one of NRIC, PASSPORT, ARMY_NO, POLICE_NO. |
nationality, hasPr, isResident | ||
joinDate, leaveDate | string? | ISO date. |
isArchived | boolean | |
notPayableReason | string? | "No payroll details yet", "Archived", or null when payable. |
missing | string[] | Statutory gaps a run's readiness check would also report (e.g. a missing IC). |
profileIncompleteSections | string[] | Which of Personal, Employment, Statutory is short of what a run needs to include this person at all. |
hasPayrollProfile | boolean | false = no payroll details saved yet; employeeProfileId is a placeholder. |
curl https://<api-host>/payroll/employees?includeArchived=false \
-H "Authorization: Bearer wp_live_xxx"
A blank import workbook with one example row (skipped on import).
Request
| Query param | Type | Meaning |
|---|---|---|
format | string | Xlsx (default) or Csv. |
Response 200
The file (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet or text/csv) named payroll-employees-template.<ext>.
The same sheet, pre-filled with every current employee and supervisor — including anyone with hasPayrollProfile: false, as a blank row. Edit it and re-upload to /import.
Request
| Query param | Type | Meaning |
|---|---|---|
format | string | Xlsx (default) or Csv. |
Response 200
The file, named payroll-employees.<ext>.
Bulk-fill statutory and salary details. Updates existing employees only — it never creates a user or a login, so a row must match someone already in the org.
Request — multipart/form-data
| Field | Type | Meaning |
|---|---|---|
file | file | Required. .csv or .xlsx (anything else is read as xlsx). |
Columns (identified by header text; aliases accepted; * = effectively required — a row with neither email nor name cannot be matched):
| Column | Aliases | Notes |
|---|---|---|
Employee Email * | — | Matches an existing member. Email or Name is required to identify the row. |
Employee Name * | — | Used when email is blank; refused if ambiguous. |
IC / Passport No | ic, ic no, nric, passport, personal id | |
ID Type | id type | NRIC, PASSPORT, ARMY_NO, POLICE_NO. |
Nationality | — | Mapped to the app's own spelling (e.g. "Malaysia" → "Malaysian"); unrecognised values are kept as typed and reported as a warning. |
Gender | — | |
Marital Status | marital status | |
Spouse Working | spouse working | Yes/No. Leave blank for "unknown" — drives the PCB spouse-relief branch; do not default to No. |
Date of Birth | dob, birth date | Date. |
Join Date | date joined, start date | Date. |
Leave Date | date left, resignation date | Date. |
Department | — | |
Salary Type | pay type | MONTHLY or HOURLY. |
Monthly Salary | basic salary, salary | Tolerant of "RM 5,000.00"; negative or unparseable fails the row. |
Hourly Rate | — | Same parsing as Monthly Salary. |
EPF No | kwsp, kwsp no, epf | |
EPF Employee Rate % | epf rate | |
Contribute to EPF | — | Yes/No. |
SOCSO No | perkeso, socso | Blank defaults to the IC/passport number, matching the app. |
SOCSO Scheme | socso scheme, perkeso scheme | EMPLOYMENT_INJURY_INVALIDITY or EMPLOYMENT_INJURY_ONLY. |
Contribute to EIS | — | Yes/No. |
Income Tax No | lhdn, tax no, pcb no | |
Bank Name | bank | |
Bank Account No | account no, bank account | |
Bank Account Holder | — |
Response 200
| Field | Type | Meaning |
|---|---|---|
imported, skipped, failed | int | Row counts. skipped counts the example row. |
errors | array of { row, message } | Row is 1-based including the header; capped at 200. |
warnings | array of { row, message } | Row imported, but worth a look (e.g. an unrecognised nationality). |
Errors
400 { "error": "No file was uploaded." } or { "error": "Missing required column(s): …" } for a file missing both identity columns.
curl -X POST https://<api-host>/payroll/employees/import \
-H "Authorization: Bearer wp_live_xxx" \
-F file=@payroll-employees.xlsx
Salary changes
One employee's salary history. A change is recorded only as a side effect of editing the salary itself — through the bulk import below, or an employee's profile in the app — so the history can never disagree with what is actually on file. There is no endpoint to write a history row directly.
An xlsx workbook pre-filled with every active payroll employee and their current salary, plus a "How to fill" sheet.
Response 200
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, named salary-adjustments-<yyyy-MM-dd>.xlsx.
New salaries for many employees at once — an annual increment round. All or nothing: if any row has a problem, nothing is saved and every problem is listed.
Request — multipart/form-data
| Field | Type | Meaning |
|---|---|---|
file | file | Required. .csv or .xlsx. |
Columns — identified by Employee No or Email; a row with a blank New Salary is skipped (that person's pay isn't changing):
| Column | Required | Notes |
|---|---|---|
Employee No | One of No/Email | Aliases: employee number, staff id, employee id. |
Email | One of No/Email | |
Name, Salary Type, Current Salary | No | Read-only, for reference — never written back. |
New Salary | Yes (to act on the row) | A monthly salary for MONTHLY staff, an hourly rate for HOURLY staff. Must be > 0. |
Effective Date | No | Blank = today. Cannot be in the future — the new salary applies immediately, since there is one current salary per employee. |
Reason | No | RAISE (default), PROMOTION, DEMOTION, RESTRUCTURE, OTHER. |
Notes | No | Max 500 characters. |
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | |
changed | int | Recorded in the salary history. |
firstSalaries | int | Salary set for the first time — no history entry, since there was nothing to change from. |
unchanged | int | New Salary equalled the current one. |
Errors
400 on any row problem: { "ok": false, "message": "…file-level…" }
or { "ok": false, "errors": [{ "row": 4, "message": "Jane Tan: New Salary must be an amount above 0." }] }.
curl -X POST https://<api-host>/payroll/salary-changes/import \
-H "Authorization: Bearer wp_live_xxx" \
-F file=@salary-adjustments.xlsx
One employee's salary history, newest activity included, oldest-to-current recorded as it happened.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
id, employeeProfileId | string | |
effectiveDate | string | ISO date. |
previousSalaryType, newSalaryType | string | MONTHLY / HOURLY. |
previousMonthlySalary, previousHourlyRate | decimal? | |
newMonthlySalary, newHourlyRate | decimal? | |
reason, reasonLabel | string | |
raisePercent | decimal? | Null across a MONTHLY↔HOURLY switch, where a percentage is meaningless. |
notes | string? | |
changedByUserId, changedByName | string? | Null for a change recorded by an automated process. |
createdAt | string | ISO timestamp. |
curl https://<api-host>/payroll/salary-changes/emp_123 \
-H "Authorization: Bearer wp_live_xxx"
Pay items (adjustment categories)
The catalogue every one-off and recurring payslip line is coded against — allowances, deductions, benefits-in-kind and remuneration — served rather than hardcoded because the calculator dispatches on these exact codes.
Every adjustment category this org's payroll engine understands.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
code | string | The exact, case-sensitive key used on a run adjustment. |
label | string | Display name. |
kind | string | ALLOWANCE, DEDUCTION, or REIMBURSEMENT. |
group | string | ALLOWANCE, REMUNERATION, BENEFIT_IN_KIND or DEDUCTION — derived from the code's prefix, for grouping a picker. |
subjectToEpf, subjectToSocso, subjectToEis, subjectToPcb, subjectToHrdf | boolean | Which wage bases this row feeds. |
taxExemptLimit | decimal? | Annual ringgit ceiling under which the row is PCB-exempt (allowance), or the item's yearly cap (TP1 deduction). |
reducesBase, reducesGross | boolean | |
cashNeutral | boolean | Doesn't move take-home pay (e.g. a TP1 relief). |
feedsLp1Relief | boolean | Counts toward the TP1 relief total (ΣLP). |
addsToCp38Field | boolean | |
addsToStandardPcb | boolean | |
isAdditionalRemuneration | boolean | Bonus/commission-style — taxed via the AR step, reported apart from salary on EA/CP8D. |
offsetsPcb | boolean | e.g. self-paid zakat, the D1b departure-levy rebate. |
nonCash | boolean | A benefit in kind: taxable but never part of gross or net pay. |
curl https://<api-host>/payroll/adjustment-categories \
-H "Authorization: Bearer wp_live_xxx"
Payslips
The caller's own payslips. There is deliberately no employee-id parameter here — WHOSE payslips these are comes from the caller's own identity.
EmployeeProfile to resolve "me" against. GET /payslips always
returns an empty array for a key, and GET /payslips/{id},
/pdf and /tp1 always 404. To read or file a
specific employee's payslips as an integrator, use the admin endpoints on the
Payroll runs page. These endpoints also need the
org's plan to include Payroll, like the rest of this page.
The caller's own payslip list, one row per submitted month.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
id | string | |
periodYear, periodMonth | int | |
periodLabel | string | e.g. "January 2026". |
grossPay, netPay | decimal | |
epfEmployee, socsoEmployee, eisEmployee | decimal | |
pcb | decimal | Tax actually withheld — includes any Additional PCB. |
submittedAt | string? | ISO timestamp the run went live. |
The full payslip: identity and salary as snapshotted at generation, every statutory figure, and the line items.
periodYear /
periodMonth / submittedAt — read those off the list
endpoint above, matched by id.
Response 200 (selected fields — the full payslip has ~40)
| Field | Type | Meaning |
|---|---|---|
id, employeeProfileId, userId | string | |
snapshotName, snapshotEmployeeNumber, snapshotPosition | string? | As they were when the run was generated. |
snapshotSalaryType, snapshotMonthlySalary, snapshotHourlyRate | ||
basicPay, proratedPay, otPay | decimal | |
totalAllowances, totalReimbursements, totalDeductions, totalBenefitsInKind | decimal | |
epfEmployee, epfEmployer, socsoEmployee, socsoEmployer, eisEmployee, eisEmployer, skbbkEmployee | decimal | |
pcb, pcbNormal, pcbAdditional, voluntaryPcb, cp38, zakat, hrdf | decimal | |
pcbCalculationJson | string? | The LHDN formula decomposition, as a JSON string. Null for payslips generated before this feature shipped. |
grossPay, netPay, totalCostToEmployer | decimal | |
statutoryWarnings | string[] | |
lineItems | array | { id, kind, label, amount, category, pcbTaxableAmount, claimId, subjectToEpf, subjectToSocso, subjectToEis, subjectToPcb } each. |
Errors
404 empty body — no such payslip, it isn't the caller's, or its run isn't submitted. The three cases are deliberately indistinguishable.
The payslip as a PDF (bank account masked).
Response 200
application/pdf.
Errors
404 empty — not found/not the caller's/not submitted. 409 { "error": "…" } if the document can't be rendered (e.g. a data problem naming the employee).
The caller's own Borang PCB/TP1 for this payslip's month (reliefs claimed this month and year-to-date).
Response 200
application/pdf.
Errors
Same as the payslip PDF above.
curl https://<api-host>/payslips/pay_123/pdf \
-H "Authorization: Bearer wp_live_xxx" -o payslip.pdf
Loans
Staff loans and salary advances, repaid by deduction from payroll. Admin-only — an employee sees their own repayments on their payslip, not here.
employeeProfileId with hasPayrollProfile: false (a placeholder)
is refused — fill in that person's payroll details first.
Locked months never change: once a month is submitted or awaiting
approval, its installment is fixed; use Replan to re-spread what's left, not Update.
A loan that has started repaying cannot be deleted — cancel it instead, so the
repayments already filed stay explained.
Request
| Query param | Type | Meaning |
|---|---|---|
employeeProfileId | string | Optional — narrow to one person. |
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
id, employeeProfileId, employeeName | string | |
principalAmount | decimal | |
mode | string | FIXED ("over N months") or CUSTOM ("RM X a month"); always CUSTOM after a re-plan. |
installmentAmount, installmentCount | decimal, int | |
startYear, startMonth | int | |
status | string | ACTIVE, COMPLETED, CANCELLED, PAUSED. |
schedule | array | { index, year, month, periodLabel, amount, paid, paused, locked } per installment. |
paidInstallments, paidAmount, remainingAmount | ||
endYear, endMonth, fullyRepaid | ||
hasStarted | boolean | At least one installment is locked; Update is refused, use Replan. |
pausedFromYear, pausedFromMonth | int? | Set only while PAUSED. |
firstEditableYear, firstEditableMonth | int | Earliest month Replan/Skip/Pause can touch. |
remainingToPlan | decimal | What a re-plan has to spread: principal less every locked installment. |
warnings | string[] | Advisory (e.g. deductions past the leaving date). The plan saves regardless. |
Same shape as above, one loan. 404 if not found.
Record a new loan.
Request
| Field | Type | Required | Meaning |
|---|---|---|---|
employeeProfileId | string | Yes | Must already have a payroll profile. |
principalAmount | decimal | Yes | 0.01–10,000,000. |
mode | string | No (default FIXED) | FIXED or CUSTOM. |
installmentCount | int | If mode=FIXED | 1–600. |
installmentAmount | decimal | If mode=CUSTOM | 0.01–10,000,000. |
startYear, startMonth | int | Yes | Year 2000–2100, month 1–12. |
notes | string | No | Max 500. |
schedule | decimal[] | No | A hand-varied schedule; must sum to principalAmount. Omitted = equal split. |
Response 200
The created EmployeeLoanDto (same shape as the list row).
Errors
400 { "error": "This employee has no payroll profile yet. Open them under Payroll → Employees, save their details, then record the loan." } — and similarly for terms that don't resolve to a repayable schedule.
curl -X POST https://<api-host>/payroll/loans \
-H "Authorization: Bearer wp_live_xxx" -H "Content-Type: application/json" \
-d '{"employeeProfileId":"emp_123","principalAmount":1200.00,"mode":"FIXED","installmentCount":6,"startYear":2026,"startMonth":2}'
Replaces the loan's terms. Refused once the loan has started repaying (hasStarted: true) — use Replan — or while it is PAUSED (resume first).
Request
Same body as Create (employeeProfileId here reassigns the loan).
Response
200 with the updated loan, or 404.
Errors
400 { "error": "This loan has already started repaying, so the amount lent and the months already deducted are fixed. Use Re-plan to change what is still owed." }
Stops future deductions; repayments already taken stay explained on their payslips. No body. 200 with the updated loan, or 404.
No body. 200/404. 400 if the loan isn't currently cancellable back to active (e.g. it's PAUSED — resume instead).
Re-spread what a started loan still owes. Locked (submitted/awaiting-approval) months are untouched; re-planning always sets mode to CUSTOM.
Request
| Field | Type | Meaning |
|---|---|---|
mode | string | FIXED or CUSTOM, for how the remainder is spread. |
installmentCount | int? | 1–600, if FIXED. |
installmentAmount | decimal? | If CUSTOM. |
remainder | decimal[]? | Typed month by month from firstEditableYear/Month onwards; must sum to remainingToPlan. |
200 with the updated loan, or 404. 400 on terms that don't add up.
Make a run of months deduct RM 0 each; every later installment moves back so the balance still repays in full.
Request
| Field | Type | Meaning |
|---|---|---|
fromYear, fromMonth | int | |
months | int | 1–24, default 1. |
200/404. 400 { "error": "… is already submitted or awaiting approval. The earliest month that can change is …" } if the start falls before firstEditableYear/Month.
curl -X POST https://<api-host>/payroll/loans/loan_123/skip \
-H "Authorization: Bearer wp_live_xxx" -H "Content-Type: application/json" \
-d '{"fromYear":2026,"fromMonth":3,"months":1}'
Deducts nothing from the given month onward until resumed.
Request
{ fromYear, fromMonth } (int, int).
200/404. 400 if the loan isn't ACTIVE.
Deductions restart from the given month; the paused months are written in as RM 0 and the rest moves later.
Request
{ year, month } (int, int) — must be on or after the pause started, and on or after the first editable month.
200/404. 400 { "error": "Only a paused loan can be resumed." } or a month-ordering message.
204 on success, 404 if not found.
Errors
400 { "error": "This loan has already been deducted from at least one submitted run, so it cannot be deleted. Cancel it instead — the repayments stay explained." }
Settings & company info
Org-level payroll configuration. Both GETs return defaults rather than 404
for an org that hasn't configured payroll yet (isConfigured: false).
The bank register the bank-file generators match an employee's bankName
against. An unrecognised name refuses the whole payment-schedule/bank file, so pick
from here rather than free text.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
name | string | e.g. "Maybank". |
aliases | string[] | Other names that match the same bank. |
Response 200
| Field | Type | Meaning |
|---|---|---|
workingDaysRule | string | CALENDAR or TWENTY_SIX — the ÷ divisor for the ordinary rate of pay. |
defaultEpfEmployeeRate, defaultEpfEmployerRate | decimal | Percent. |
hrdfEnabled, hrdfRate | boolean, decimal? | |
autoApplySocsoEisRelief | boolean | |
syncClaimsToXeroOnSubmit, syncPayrollToXeroOnSubmit, xeroMappingJson | ||
payrollBankName, payorAccountHolderName, payorOrganisationCode, ecpPayorAccountNo, ecpPayorBic | string? | The payor side of the bank file. |
isConfigured | boolean | false = these are statutory defaults, never saved. |
updatedAt | string? |
Request
| Field | Type | Required | Limits |
|---|---|---|---|
workingDaysRule | string | Yes | CALENDAR / TWENTY_SIX. |
defaultEpfEmployeeRate | decimal | No (default 11) | 0–100. |
defaultEpfEmployerRate | decimal | No (default 13) | 0–100. |
hrdfEnabled | boolean | No | |
hrdfRate | decimal? | No | 0–100. Cleared server-side when hrdfEnabled is false. |
autoApplySocsoEisRelief | boolean | No (default true) | |
syncClaimsToXeroOnSubmit, syncPayrollToXeroOnSubmit, xeroMappingJson | No | ||
payrollBankName | string? | No | Max 120. |
payorAccountHolderName | string? | No | Max 160. |
payorOrganisationCode | string? | No | Max 60. |
ecpPayorAccountNo, ecpPayorBic | string? | No | Max 20. |
Response
200 with the saved PayrollSettingsDto.
curl -X PUT https://<api-host>/payroll/settings \
-H "Authorization: Bearer wp_live_xxx" -H "Content-Type: application/json" \
-d '{"workingDaysRule":"TWENTY_SIX","defaultEpfEmployeeRate":11,"defaultEpfEmployerRate":13,"autoApplySocsoEisRelief":true}'
The employer's registration and filing identity — LHDN, SSM, PERKESO, EPF, HRDF, zakat, address, and the declarant used on statutory forms.
Response 200 (selected fields)
| Field | Type | Meaning |
|---|---|---|
employerName, employerTin | string? | LHDN E-number. |
registrationNo | string? | SSM number. |
perkesoEmployerCode, epfEmployerNo, hrdfEmployerNo, zakatNumber | string? | |
addressLine1, addressLine2, postcode, city, state, country | string? | |
phone, handphone, email | string? | |
taxAgentName, taxAgentTin, taxAgentLicenceNo, taxAgentPhone, taxAgentEmail | string? | |
taxAgentFirmName, taxAgentFirmAddressLine1/2, taxAgentFirmPostcode, taxAgentFirmCity, taxAgentFirmState | string? | |
declarantName, declarantIdType, declarantIdNumber, declarantPosition | The person named on Form E / CP8D. | |
isConfigured, updatedAt |
Company Info needs employer name, LHDN E-number, SSM number and PERKESO code all filled before a run can be submitted (the readiness gate) — the income tax number is not required.
Same fields as the GET response (minus isConfigured/updatedAt), each with a max length; email and taxAgentEmail are validated as email addresses. A blank country is saved as "Malaysia".
Response
200 with the saved PayrollCompanyInfoDto.
Annual forms
Year-end filings: Form EA, Form E + CP8D, the LHDN e-CP8D upload pair, and PCB 2(II). An employee's own EA reaches them through the payslips surface, not here.
SUBMITTED.
PCB 2(II) is the one exception — a statement of what has been deducted so far — and is
available at any point in the year.
What can be produced, independent of year.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
kind | string | FORM_EA_BULK_PDF, FORM_E_CP8D_PDF, CP8D_EMPLOYER_TXT, CP8D_EMPLOYEE_TXT, PCB2II_BULK_PDF. |
group | string | FORMS or LHDN_TXT. |
title, description | string | |
portal | string? | Where the file is uploaded, if anywhere (e.g. "LHDN e-CP8D upload"). |
extension, mimeType | string | |
requiresFullYear | boolean | false only for PCB 2(II). |
The year aggregated from every SUBMITTED run — what the forms will say, before downloading one. Large: one row per employee.
Response 200 (selected fields)
| Field | Type | Meaning |
|---|---|---|
year, organizationName, employerNo | employerNo is the TIN with letters/punctuation stripped. | |
submittedMonths | int[] | 1–12, which months are approved. |
missingMonths | int[] | 1–12 not yet approved. |
canGenerate | boolean | missingMonths is empty — the full-year forms would succeed. |
employees | array | Per-employee year totals: employeeProfileId, employeeName, employeeCode, identity fields, grossSalary, bonusAndCommission, totalBik, totalPcb, totalMtdRemitted, totalCp38, totalZakat, totalEpfEmployee, totalSocsoEmployee, totalEisEmployee, totalIncome, and a months array of { month, pcb, cp38, zakat }. (The full row also carries the EA form's own line-by-line figures.) |
kind is one of the values from /payroll/annual/reports above, in the path.
Response 200
The file, content type and filename per that report's mimeType/extension (e.g. Form_EA_2026_Bulk.pdf, P{employerNo}_2026.TXT).
Errors
409 { "error": "x/12 monthly runs approved for 2026. The annual forms cover the full January–December year, so approve every month first (missing: …)." }
for a full-year report with gaps; 409 naming a missing Company Info field for the forms that need one.
curl https://<api-host>/payroll/annual/2026/reports/FORM_EA_BULK_PDF \
-H "Authorization: Bearer wp_live_xxx" -o form-ea-2026.pdf
Hand-typed CP8D rows in, the zipped employer (M) + employee (P) TXT pair out. For years this system did not run payroll — nothing is read from or written to payroll data.
Request
| Field | Type | Required | Notes |
|---|---|---|---|
employerNo | string | Yes | Max 40; digits are extracted for the filenames. |
employerName | string | Yes | Max 200. |
year | int | Yes | 2000–2100. |
employees | array | Yes, ≥1 | See below. |
Each row of employees:
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | Yes | Max 200. |
taxRef | string | Yes | Max 40. LHDN income tax reference. |
newIc | string | Yes | Max 40. A value with letters is filed as a passport, not padded into an IC. |
category | string | No (default "1") | "1" single, "2" married/sole earner, "3" both working / divorced / widowed / single with children. |
taxBorneByEmployer | boolean | No | |
children | int | No | 0–50. |
childRelief, annualGross, epf, pcb | decimal | No | 0–100,000,000. |
status | int | No (default 2) | 1 management · 2 permanent · 3 contract · 4 part-time · 5 industrial trainee · 6 other. |
retirementDate | string? | No | Retirement, contract end, or cessation date. |
benefitsInKind, livingAccommodation, esos, taxExempt, tp1Relief, tp1Zakat, zakat, cp38, perkeso | decimal | No | 0–100,000,000; blank is filed as nil. |
Response 200
application/zip named CP8D_{employerNo}_{year}.zip, containing the M and P TXT files.
Errors
400 standard validation errors; 409 { "error": "The employer's LHDN E-number must contain digits." }.
Year-to-date import
Seed payroll history when an org migrates mid-year, so the PCB engine has the year-to-date figures it needs to tax the remaining months correctly. Deliberately two-step: preview shows the match list and writes nothing, then import commits it.
COMPUTED by this system is never
overwritten (silently skipped — see skippedMonths); a month previously
IMPORTED is replaced. Imported months land as SUBMITTED
immediately — there is no draft step — because only submitted runs count toward
year-to-date.
Request
| Param | Type | Meaning |
|---|---|---|
year | int, path | |
format | string, query | Xlsx (default, a 3-sheet workbook pre-filled with the roster) or Csv (flat). |
Columns — Full Name and Personal ID, then twelve month rows per employee with:
| Mandatory | Optional (add/remove as needed) |
|---|---|
| Basic Salary, PCB, Employee EPF, Employee SOCSO, Employee EIS, Employer EPF, Employer SOCSO, Employer EIS, HRDF | Bonus, Commission, Overtime, Service Charge, Travel/Petrol Allowance, Parking Allowance, Phone/Broadband Allowance, Other Allowance, Unpaid Leave, Net Salary Deduction, Zakat, Employee SKBBK (June 2026 onwards only) |
Any adjustment-category label from /payroll/adjustment-categories also works as a column header — the amount is routed through that category's statutory rules (a benefit in kind lands as non-cash, a deduction comes off net).
Parses the file and reports what WOULD happen. Writes nothing.
Request — multipart/form-data
file (required).
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | |
errors | string[] | File-level problems. |
warnings | string[] | Unrecognised columns, months that will be skipped or replaced. |
employees | array | { employeeName, employeeProfileId, months: int[], totalGross, totalPcb } per matched person. |
unmatchedNames | string[] | Sheet names that matched nobody. |
Errors
400 with the same body shape when ok: false (e.g. an unreadable file).
Commits the import. Matches by IC first, then by name (never guessed at if ambiguous).
Request
Same as preview.
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | |
errors | string[] | |
monthsImported, payslipsImported | int | |
unmatchedNames | string[] | |
skippedMonths | string[] | Period labels left alone because this system already computed them. |
Errors
400 { "ok": false, "errors": ["None of the names in the sheet matched an employee in this organisation."] } or similar.
curl -X POST https://<api-host>/payroll/ytd-import/2026 \
-H "Authorization: Bearer wp_live_xxx" \
-F file=@ytd-2026.xlsx
Portal credentials
Saved logins for KWSP i-Akaun, PERKESO ASSIST and LHDN e-PCB, so whoever is filing doesn't have to hunt for them every deadline.
password is replaced wholesale on a
PUT — omit one and it is cleared, not left alone. password alone is
three-way: omit/null to leave the stored password untouched, send
"" to clear it, send anything else to replace it.
All three portals, configured or not, passwords always masked here.
Response 200 — array of
| Field | Type | Meaning |
|---|---|---|
portal | string | KWSP, PERKESO, or LHDN. |
portalLabel | string | e.g. "KWSP i-Akaun". |
loginId | string? | |
password | null | Always null on the list — fetch /reveal for the value. |
hasPassword | boolean | Whether one is stored. |
image, secretCode, securityPhrase, passwordReminder, notes | string? | |
isConfigured | boolean | false = nothing saved for this portal yet. |
updatedAt | string? |
portal in the path is KWSP, PERKESO or LHDN. Returns the password in clear. Reading it is audited.
Response
200 — same shape as the list row, with password populated. 404 if nothing is saved for that portal.
curl https://<api-host>/payroll/portal-credentials/LHDN/reveal \
-H "Authorization: Bearer wp_live_xxx"
Request
| Field | Type | Limits |
|---|---|---|
loginId | string? | Max 120. |
password | string? | Omit/null = unchanged; "" = clear; else replaced. |
image | string? | Max 120. |
secretCode, securityPhrase, passwordReminder | string? | Max 200 each. |
notes | string? | Max 2000. |
Response
200 with the saved credential (password masked).
204 on success, 404 if nothing was saved for that portal.
Not available to API keys (human-only, omitted above): none in this module — every Payroll action documented here is reachable with a key. (Payroll run generation, submission, approval and statutory-file/document downloads are on Payroll runs, a separate page.)