Developer docs
Payroll
REST · JSON · API keys

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.

Two ids, two systems. This list returns 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).
GET /payroll/employees payroll:read

The whole payroll roster for employees and supervisors (an Admin or Owner is never on payroll).

Request

Query paramTypeMeaning
includeArchivedbooleanDefault false. Include archived employees.

Response 200 — array of

FieldTypeMeaning
employeeProfileIdstringThe id every other payroll endpoint takes.
userIdstringThe id /employees calls id.
name, emailstring
employeeNumber, department, jobTitlestring?
salaryTypestring"MONTHLY" or "HOURLY".
monthlySalary, hourlyRatedecimal?Only one applies, per salaryType.
contributeToEpf, contributeToEisboolean
epfNumber, epfEmployeeRatestring?, decimal
socsoNumberstring?
incomeTaxNumberstring?Not required for a run to include someone — see Readiness below.
paymentMethodstring"BANK_TRANSFER", "CASH" or "CHEQUE".
bankName, bankAccountNumberstring?
idNumber, idTypestring?idType one of NRIC, PASSPORT, ARMY_NO, POLICE_NO.
nationality, hasPr, isResident
joinDate, leaveDatestring?ISO date.
isArchivedboolean
notPayableReasonstring?"No payroll details yet", "Archived", or null when payable.
missingstring[]Statutory gaps a run's readiness check would also report (e.g. a missing IC).
profileIncompleteSectionsstring[]Which of Personal, Employment, Statutory is short of what a run needs to include this person at all.
hasPayrollProfilebooleanfalse = no payroll details saved yet; employeeProfileId is a placeholder.
curl https://<api-host>/payroll/employees?includeArchived=false \
  -H "Authorization: Bearer wp_live_xxx"
GET /payroll/employees/template payroll:read

A blank import workbook with one example row (skipped on import).

Request

Query paramTypeMeaning
formatstringXlsx (default) or Csv.

Response 200

The file (application/vnd.openxmlformats-officedocument.spreadsheetml.sheet or text/csv) named payroll-employees-template.<ext>.

GET /payroll/employees/export payroll:read

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 paramTypeMeaning
formatstringXlsx (default) or Csv.

Response 200

The file, named payroll-employees.<ext>.

POST /payroll/employees/import payroll:write

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

FieldTypeMeaning
filefileRequired. .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):

ColumnAliasesNotes
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 Noic, ic no, nric, passport, personal id
ID Typeid typeNRIC, 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 Statusmarital status
Spouse Workingspouse workingYes/No. Leave blank for "unknown" — drives the PCB spouse-relief branch; do not default to No.
Date of Birthdob, birth dateDate.
Join Datedate joined, start dateDate.
Leave Datedate left, resignation dateDate.
Department—
Salary Typepay typeMONTHLY or HOURLY.
Monthly Salarybasic salary, salaryTolerant of "RM 5,000.00"; negative or unparseable fails the row.
Hourly Rate—Same parsing as Monthly Salary.
EPF Nokwsp, kwsp no, epf
EPF Employee Rate %epf rate
Contribute to EPF—Yes/No.
SOCSO Noperkeso, socsoBlank defaults to the IC/passport number, matching the app.
SOCSO Schemesocso scheme, perkeso schemeEMPLOYMENT_INJURY_INVALIDITY or EMPLOYMENT_INJURY_ONLY.
Contribute to EIS—Yes/No.
Income Tax Nolhdn, tax no, pcb no
Bank Namebank
Bank Account Noaccount no, bank account
Bank Account Holder—
A blank cell leaves that field unchanged — this is not an all-or-nothing import. Every row is applied independently and reported with its own success or failure, so a partial sheet ("here are everyone's bank details") is safe to upload over a roster that already has statutory numbers on it. A member who is an Admin or Owner is rejected (payroll is for employees and supervisors only).

Response 200

FieldTypeMeaning
imported, skipped, failedintRow counts. skipped counts the example row.
errorsarray of { row, message }Row is 1-based including the header; capped at 200.
warningsarray 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.

Recording a salary change (including via import) marks every draft payroll run stale — re-run (regenerate) any affected draft before submitting it, or it will still reflect the old salary.
GET /payroll/salary-changes/import/template payroll:read

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.

POST /payroll/salary-changes/import payroll:write

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

FieldTypeMeaning
filefileRequired. .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):

ColumnRequiredNotes
Employee NoOne of No/EmailAliases: employee number, staff id, employee id.
EmailOne of No/Email
Name, Salary Type, Current SalaryNoRead-only, for reference — never written back.
New SalaryYes (to act on the row)A monthly salary for MONTHLY staff, an hourly rate for HOURLY staff. Must be > 0.
Effective DateNoBlank = today. Cannot be in the future — the new salary applies immediately, since there is one current salary per employee.
ReasonNoRAISE (default), PROMOTION, DEMOTION, RESTRUCTURE, OTHER.
NotesNoMax 500 characters.

Response 200

FieldTypeMeaning
okboolean
changedintRecorded in the salary history.
firstSalariesintSalary set for the first time — no history entry, since there was nothing to change from.
unchangedintNew 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
GET /payroll/salary-changes/{employeeProfileId} payroll:read

One employee's salary history, newest activity included, oldest-to-current recorded as it happened.

Response 200 — array of

FieldTypeMeaning
id, employeeProfileIdstring
effectiveDatestringISO date.
previousSalaryType, newSalaryTypestringMONTHLY / HOURLY.
previousMonthlySalary, previousHourlyRatedecimal?
newMonthlySalary, newHourlyRatedecimal?
reason, reasonLabelstring
raisePercentdecimal?Null across a MONTHLY↔HOURLY switch, where a percentage is meaningless.
notesstring?
changedByUserId, changedByNamestring?Null for a change recorded by an automated process.
createdAtstringISO 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.

Codes are case-sensitive and only the ones this endpoint returns are valid on a run adjustment (documented on the Payroll runs page). Fetch this list rather than hardcoding codes — the catalogue can grow.
GET /payroll/adjustment-categories payroll:read

Every adjustment category this org's payroll engine understands.

Response 200 — array of

FieldTypeMeaning
codestringThe exact, case-sensitive key used on a run adjustment.
labelstringDisplay name.
kindstringALLOWANCE, DEDUCTION, or REIMBURSEMENT.
groupstringALLOWANCE, REMUNERATION, BENEFIT_IN_KIND or DEDUCTION — derived from the code's prefix, for grouping a picker.
subjectToEpf, subjectToSocso, subjectToEis, subjectToPcb, subjectToHrdfbooleanWhich wage bases this row feeds.
taxExemptLimitdecimal?Annual ringgit ceiling under which the row is PCB-exempt (allowance), or the item's yearly cap (TP1 deduction).
reducesBase, reducesGrossboolean
cashNeutralbooleanDoesn't move take-home pay (e.g. a TP1 relief).
feedsLp1ReliefbooleanCounts toward the TP1 relief total (ΣLP).
addsToCp38Fieldboolean
addsToStandardPcbboolean
isAdditionalRemunerationbooleanBonus/commission-style — taxed via the AR step, reported apart from salary on EA/CP8D.
offsetsPcbbooleane.g. self-paid zakat, the D1b departure-levy rebate.
nonCashbooleanA 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.

An API key has no payslips of its own. A key authenticates as a synthetic machine identity in its org, not as any one employee, so it has no 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.
GET /payslips payroll:read

The caller's own payslip list, one row per submitted month.

Response 200 — array of

FieldTypeMeaning
idstring
periodYear, periodMonthint
periodLabelstringe.g. "January 2026".
grossPay, netPaydecimal
epfEmployee, socsoEmployee, eisEmployeedecimal
pcbdecimalTax actually withheld — includes any Additional PCB.
submittedAtstring?ISO timestamp the run went live.
GET /payslips/{id} payroll:read

The full payslip: identity and salary as snapshotted at generation, every statutory figure, and the line items.

The detail object does not repeat periodYear / periodMonth / submittedAt — read those off the list endpoint above, matched by id.

Response 200 (selected fields — the full payslip has ~40)

FieldTypeMeaning
id, employeeProfileId, userIdstring
snapshotName, snapshotEmployeeNumber, snapshotPositionstring?As they were when the run was generated.
snapshotSalaryType, snapshotMonthlySalary, snapshotHourlyRate
basicPay, proratedPay, otPaydecimal
totalAllowances, totalReimbursements, totalDeductions, totalBenefitsInKinddecimal
epfEmployee, epfEmployer, socsoEmployee, socsoEmployer, eisEmployee, eisEmployer, skbbkEmployeedecimal
pcb, pcbNormal, pcbAdditional, voluntaryPcb, cp38, zakat, hrdfdecimal
pcbCalculationJsonstring?The LHDN formula decomposition, as a JSON string. Null for payslips generated before this feature shipped.
grossPay, netPay, totalCostToEmployerdecimal
statutoryWarningsstring[]
lineItemsarray{ 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.

GET /payslips/{id}/pdf payroll:read

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

GET /payslips/{id}/tp1 payroll:read

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.

Needs a real payroll profile. Creating a loan against an 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.
GET /payroll/loans payroll:read

Request

Query paramTypeMeaning
employeeProfileIdstringOptional — narrow to one person.

Response 200 — array of

FieldTypeMeaning
id, employeeProfileId, employeeNamestring
principalAmountdecimal
modestringFIXED ("over N months") or CUSTOM ("RM X a month"); always CUSTOM after a re-plan.
installmentAmount, installmentCountdecimal, int
startYear, startMonthint
statusstringACTIVE, COMPLETED, CANCELLED, PAUSED.
schedulearray{ index, year, month, periodLabel, amount, paid, paused, locked } per installment.
paidInstallments, paidAmount, remainingAmount
endYear, endMonth, fullyRepaid
hasStartedbooleanAt least one installment is locked; Update is refused, use Replan.
pausedFromYear, pausedFromMonthint?Set only while PAUSED.
firstEditableYear, firstEditableMonthintEarliest month Replan/Skip/Pause can touch.
remainingToPlandecimalWhat a re-plan has to spread: principal less every locked installment.
warningsstring[]Advisory (e.g. deductions past the leaving date). The plan saves regardless.
GET /payroll/loans/{id} payroll:read

Same shape as above, one loan. 404 if not found.

POST /payroll/loans payroll:write

Record a new loan.

Request

FieldTypeRequiredMeaning
employeeProfileIdstringYesMust already have a payroll profile.
principalAmountdecimalYes0.01–10,000,000.
modestringNo (default FIXED)FIXED or CUSTOM.
installmentCountintIf mode=FIXED1–600.
installmentAmountdecimalIf mode=CUSTOM0.01–10,000,000.
startYear, startMonthintYesYear 2000–2100, month 1–12.
notesstringNoMax 500.
scheduledecimal[]NoA 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}'
PUT /payroll/loans/{id} payroll:write

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

POST /payroll/loans/{id}/cancel payroll:write

Stops future deductions; repayments already taken stay explained on their payslips. No body. 200 with the updated loan, or 404.

POST /payroll/loans/{id}/reactivate payroll:write

No body. 200/404. 400 if the loan isn't currently cancellable back to active (e.g. it's PAUSED — resume instead).

POST /payroll/loans/{id}/replan payroll:write

Re-spread what a started loan still owes. Locked (submitted/awaiting-approval) months are untouched; re-planning always sets mode to CUSTOM.

Request

FieldTypeMeaning
modestringFIXED or CUSTOM, for how the remainder is spread.
installmentCountint?1–600, if FIXED.
installmentAmountdecimal?If CUSTOM.
remainderdecimal[]?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.

POST /payroll/loans/{id}/skip payroll:write

Make a run of months deduct RM 0 each; every later installment moves back so the balance still repays in full.

Request

FieldTypeMeaning
fromYear, fromMonthint
monthsint1–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}'
POST /payroll/loans/{id}/pause payroll:write

Deducts nothing from the given month onward until resumed.

Request

{ fromYear, fromMonth } (int, int).

200/404. 400 if the loan isn't ACTIVE.

POST /payroll/loans/{id}/resume payroll:write

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.

DELETE /payroll/loans/{id} payroll:write

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

Both PUTs replace the whole row — every field in the body is written, including ones you omit (bound as empty/null and saved as such). Read the current value with the matching GET first and send the fields back unchanged if you only mean to update a few.
GET /payroll/banks payroll:read

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.

This endpoint is exempt from an org admin's own module grant (it's borrowed by other screens) but still needs the org's plan to include Payroll.

Response 200 — array of

FieldTypeMeaning
namestringe.g. "Maybank".
aliasesstring[]Other names that match the same bank.
GET /payroll/settings payroll:read

Response 200

FieldTypeMeaning
workingDaysRulestringCALENDAR or TWENTY_SIX — the ÷ divisor for the ordinary rate of pay.
defaultEpfEmployeeRate, defaultEpfEmployerRatedecimalPercent.
hrdfEnabled, hrdfRateboolean, decimal?
autoApplySocsoEisReliefboolean
syncClaimsToXeroOnSubmit, syncPayrollToXeroOnSubmit, xeroMappingJson
payrollBankName, payorAccountHolderName, payorOrganisationCode, ecpPayorAccountNo, ecpPayorBicstring?The payor side of the bank file.
isConfiguredbooleanfalse = these are statutory defaults, never saved.
updatedAtstring?
PUT /payroll/settings payroll:write

Request

FieldTypeRequiredLimits
workingDaysRulestringYesCALENDAR / TWENTY_SIX.
defaultEpfEmployeeRatedecimalNo (default 11)0–100.
defaultEpfEmployerRatedecimalNo (default 13)0–100.
hrdfEnabledbooleanNo
hrdfRatedecimal?No0–100. Cleared server-side when hrdfEnabled is false.
autoApplySocsoEisReliefbooleanNo (default true)
syncClaimsToXeroOnSubmit, syncPayrollToXeroOnSubmit, xeroMappingJsonNo
payrollBankNamestring?NoMax 120.
payorAccountHolderNamestring?NoMax 160.
payorOrganisationCodestring?NoMax 60.
ecpPayorAccountNo, ecpPayorBicstring?NoMax 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}'
GET /payroll/company-info payroll:read

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)

FieldTypeMeaning
employerName, employerTinstring?LHDN E-number.
registrationNostring?SSM number.
perkesoEmployerCode, epfEmployerNo, hrdfEmployerNo, zakatNumberstring?
addressLine1, addressLine2, postcode, city, state, countrystring?
phone, handphone, emailstring?
taxAgentName, taxAgentTin, taxAgentLicenceNo, taxAgentPhone, taxAgentEmailstring?
taxAgentFirmName, taxAgentFirmAddressLine1/2, taxAgentFirmPostcode, taxAgentFirmCity, taxAgentFirmStatestring?
declarantName, declarantIdType, declarantIdNumber, declarantPositionThe 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.

PUT /payroll/company-info payroll:write

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.

Form EA, Form E + CP8D and the two CP8D TXT files all cover a full January–December year: none can be produced until all twelve months of that year are 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.
GET /payroll/annual/reports payroll:read

What can be produced, independent of year.

Response 200 — array of

FieldTypeMeaning
kindstringFORM_EA_BULK_PDF, FORM_E_CP8D_PDF, CP8D_EMPLOYER_TXT, CP8D_EMPLOYEE_TXT, PCB2II_BULK_PDF.
groupstringFORMS or LHDN_TXT.
title, descriptionstring
portalstring?Where the file is uploaded, if anywhere (e.g. "LHDN e-CP8D upload").
extension, mimeTypestring
requiresFullYearbooleanfalse only for PCB 2(II).
GET /payroll/annual/{year} payroll:read

The year aggregated from every SUBMITTED run — what the forms will say, before downloading one. Large: one row per employee.

Response 200 (selected fields)

FieldTypeMeaning
year, organizationName, employerNoemployerNo is the TIN with letters/punctuation stripped.
submittedMonthsint[]1–12, which months are approved.
missingMonthsint[]1–12 not yet approved.
canGeneratebooleanmissingMonths is empty — the full-year forms would succeed.
employeesarrayPer-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.)
GET /payroll/annual/{year}/reports/{kind} payroll:read

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
POST /payroll/annual/cp8d/convert payroll:write

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

FieldTypeRequiredNotes
employerNostringYesMax 40; digits are extracted for the filenames.
employerNamestringYesMax 200.
yearintYes2000–2100.
employeesarrayYes, ≥1See below.

Each row of employees:

FieldTypeRequiredNotes
namestringYesMax 200.
taxRefstringYesMax 40. LHDN income tax reference.
newIcstringYesMax 40. A value with letters is filed as a passport, not padded into an IC.
categorystringNo (default "1")"1" single, "2" married/sole earner, "3" both working / divorced / widowed / single with children.
taxBorneByEmployerbooleanNo
childrenintNo0–50.
childRelief, annualGross, epf, pcbdecimalNo0–100,000,000.
statusintNo (default 2)1 management · 2 permanent · 3 contract · 4 part-time · 5 industrial trainee · 6 other.
retirementDatestring?NoRetirement, contract end, or cessation date.
benefitsInKind, livingAccommodation, esos, taxExempt, tp1Relief, tp1Zakat, zakat, cp38, perkesodecimalNo0–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.

A month already 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.
GET /payroll/ytd-import/template/{year} payroll:read

Request

ParamTypeMeaning
yearint, path
formatstring, queryXlsx (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:

MandatoryOptional (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).

POST /payroll/ytd-import/{year}/preview payroll:write

Parses the file and reports what WOULD happen. Writes nothing.

Request — multipart/form-data

file (required).

Response 200

FieldTypeMeaning
okboolean
errorsstring[]File-level problems.
warningsstring[]Unrecognised columns, months that will be skipped or replaced.
employeesarray{ employeeName, employeeProfileId, months: int[], totalGross, totalPcb } per matched person.
unmatchedNamesstring[]Sheet names that matched nobody.

Errors

400 with the same body shape when ok: false (e.g. an unreadable file).

POST /payroll/ytd-import/{year} payroll:write

Commits the import. Matches by IC first, then by name (never guessed at if ambiguous).

Request

Same as preview.

Response 200

FieldTypeMeaning
okboolean
errorsstring[]
monthsImported, payslipsImportedint
unmatchedNamesstring[]
skippedMonthsstring[]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.

Every field except 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.
GET /payroll/portal-credentials payroll:read

All three portals, configured or not, passwords always masked here.

Response 200 — array of

FieldTypeMeaning
portalstringKWSP, PERKESO, or LHDN.
portalLabelstringe.g. "KWSP i-Akaun".
loginIdstring?
passwordnullAlways null on the list — fetch /reveal for the value.
hasPasswordbooleanWhether one is stored.
image, secretCode, securityPhrase, passwordReminder, notesstring?
isConfiguredbooleanfalse = nothing saved for this portal yet.
updatedAtstring?
GET /payroll/portal-credentials/{portal}/reveal payroll:read

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"
PUT /payroll/portal-credentials/{portal} payroll:write

Request

FieldTypeLimits
loginIdstring?Max 120.
passwordstring?Omit/null = unchanged; "" = clear; else replaced.
imagestring?Max 120.
secretCode, securityPhrase, passwordReminderstring?Max 200 each.
notesstring?Max 2000.

Response

200 with the saved credential (password masked).

DELETE /payroll/portal-credentials/{portal} payroll:write

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