Payroll runs
One month of Malaysian payroll: create a run, generate payslips, adjust them,
route them through approval, then file and pay. Every route on this page needs
the Payroll module on the org's plan (403 otherwise)
and either payroll:read or payroll:write — see
Scopes.
The run lifecycle
A run moves through three statuses, one direction at a time (reject and revert step back):
DRAFT ──submit──> PENDING_APPROVAL ──approve──> SUBMITTED
^ | |
└───────reject─────────┘ |
└──────────────────revert───────────────────────────┘
- Only a DRAFT run can be generated, adjusted, or have claims attached or detached.
Once it moves to
PENDING_APPROVALorSUBMITTEDthose writes answer409. - SUBMITTED is final. It is the only status that feeds next month's year-to-date figures, so getting there is guarded (see Submit for approval) and getting out of it cascades: reverting a month also reverts every later submitted month in the same year.
- Statutory files, the bank file, the run reports and payslip emails all require SUBMITTED. The single payslip PDF is the one exception — it can be downloaded from a draft too, as a watermarked preview.
- A run is unique per period.
POST /payroll/runsfor a month that already has a run answers409— open the existing run instead of retrying. - Two employee ids. Every id on this page is an
employeeProfileIdfromGET /payroll/employeesor the picker below — not the user idGET /employeesreturns. Passing the wrong one reads back as "not found". - Generation is destructive. Pressing
POST /{id}/generatediscards the run's payslips and rebuilds them from the employees' current profiles. Adjustments and attached claims survive a regeneration; anything typed directly onto a payslip would not, which is why there is no such thing. - Staleness. The run object's
isStalefield istruewhen something an adjustment, a claim attachment or a profile edit touched has changed since the payslips were last generated. Submission is refused while it is true — re-run payroll first.
payroll:write only to a system that genuinely needs to run payroll, not just to read it.
Runs
Every payroll run in the org, newest first by period. Totals only — no payslips.
Response 200
An array of run objects.
curl https://<api-host>/payroll/runs \
-H "Authorization: Bearer wp_live_xxx"
The "Start a payroll run" picker: every non-archived policy, each carrying the employees under
it whose payroll profile is complete enough to be paid. Use this to build
policyIds / excludedEmployeeProfileIds for
create — an employee with an incomplete profile never appears here, so
they cannot be scoped into a run.
Response 200
| Field | Type | Meaning |
|---|---|---|
policies[].id | string | Policy id. |
policies[].name | string | |
policies[].isDefault | boolean | |
policies[].members[].employeeProfileId | string | What create and every adjustment/claim route take as the employee id. |
policies[].members[].name | string | |
policies[].members[].employeeId | string | The org's own employee number. |
policies[].members[].jobTitle | string |
curl https://<api-host>/payroll/runs/picker \
-H "Authorization: Bearer wp_live_xxx"
One run with every payslip it has generated.
Response 200
| Field | Type | Meaning |
|---|---|---|
run | object | A run object. |
payslips | payslip[] | Empty until generate has run. |
memberEmployeeProfileIds | string[] | The roster chosen when the run was created. Empty means "everyone payable" (a run created before the picker existed, or one created with no policyIds/excludedEmployeeProfileIds). |
Errors
404 — no such run in this org (empty body).
curl https://<api-host>/payroll/runs/run_123 \
-H "Authorization: Bearer wp_live_xxx"
Start a DRAFT run for a period. The id, status, totals and timestamps are the server's to set.
Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
periodYear | number | yes | 2000–2100. |
periodMonth | number | yes | 1–12. |
policyIds | string[] or null | no | Policy ids from the picker. Null or omitted means every policy — the whole eligible roster. |
excludedEmployeeProfileIds | string[] or null | no | Individual employees to leave out of an otherwise-included policy. |
Response 201
A run object, with Location pointing at GET /payroll/runs/{id}.
Errors
409 { "error": "A payroll run already exists for January 2026." } — one run per period, every time.
curl -X POST https://<api-host>/payroll/runs \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "periodYear": 2026, "periodMonth": 1 }'
Delete a DRAFT run along with its payslips, adjustments and claim attachments. Attached claims themselves survive and become free to attach to another run.
Response
204 No Content.
Errors
404— no such run (empty body).409{ "error": "Only a draft run can be deleted. Revert it to draft first." }
curl -X DELETE https://<api-host>/payroll/runs/run_123 \
-H "Authorization: Bearer wp_live_xxx"
The run object
| Field | Type | Meaning |
|---|---|---|
id | string | |
periodYear / periodMonth | number | |
periodLabel | string | e.g. "January 2026", rendered server-side. |
status | string | "DRAFT" · "PENDING_APPROVAL" · "SUBMITTED". |
source | string | "COMPUTED" (the normal path) or "IMPORTED" (seeded from a migration — its statutory files, bank file and run reports are refused; its payslips are still downloadable). |
employeeCount | number | |
totalGross / totalNet | number | MYR, run totals. |
totalEmployeeEpf / totalEmployerEpf | number | |
totalEmployeeSocso / totalEmployerSocso | number | |
totalEmployeeEis / totalEmployerEis | number | |
totalEmployeeSkbbk | number | |
totalPcb / totalCp38 / totalZakat | number | |
totalHrdf | number | |
employeesSubjectToHrdf | number | |
totalWagesSubjectToHrdf | number | |
totalCostToEmployer | number | |
generatedAt | string or null | ISO 8601. Null until generate has run. |
submittedForApprovalAt / submittedForApprovalById | string or null | Who proposed the month, and when. |
submittedAt / submittedById | string or null | Who approved it, and when — a different person from the above by design. |
approvalRejectionReason | string or null | Set by reject, cleared on the next submission. |
isStale | boolean | True only for a DRAFT whose inputs changed since it was last generated. See the lifecycle note above. |
pcbReceiptNo / pcbReceiptDate | string / string or null | Set by LHDN receipts. |
cp38ReceiptNo / cp38ReceiptDate | string / string or null | |
createdAt / updatedAt | string | ISO 8601. |
The payslip object
One per employee on a run, returned inside run detail and generate's response.
| Field | Type | Meaning |
|---|---|---|
id / employeeProfileId / userId | string | userId is the /employees id — not what the rest of this page takes. |
snapshotName / snapshotEmployeeNumber / snapshotPosition / snapshotNationality / snapshotIsResident | mixed | Identity as it was AT GENERATION — a later rename does not move a filed payslip. |
snapshotSalaryType | string | "MONTHLY" or "HOURLY". |
snapshotMonthlySalary / snapshotHourlyRate | number or null | |
totalWorkingDays / proratedDays / prorationDaysInPeriod / proratedFactor | number | Three separate day figures on purpose — the ordinary-rate basis and the proration divisor are not the same thing. |
workedHours / expectedHours | number or null | Display-only for MONTHLY staff; for HOURLY staff workedHours is the paid quantity. |
unpaidLeaveDays | number or null | |
basicPay / proratedPay | number | |
otNormalHours / otRestHours / otPublicHours / otPay | number | |
totalAllowances / totalReimbursements / totalDeductions / totalBenefitsInKind | number | |
epfEmployee / epfEmployer | number | |
socsoEmployee / socsoEmployer | number | |
eisEmployee / eisEmployer | number | |
skbbkEmployee / skbbkWage | number | |
pcb / pcbNormal / pcbAdditional | number | pcb is what was actually withheld; the PCB details document explains the breakdown behind it. |
pcbCalculationJson | string or null | The raw LHDN-form decomposition, as stored — JSON-encoded text, not a nested object. Null only on payslips generated before this field existed. |
cp38 | number | Court-ordered arrears instalment, filed separately from PCB. |
voluntaryPcb | number | "Additional PCB" — included in the CP39 file's PCB field, but not in pcb itself. |
zakat | number | |
hrdf / hrdfWage | number | Employer-only; not deducted from the employee. |
grossPay / netPay / totalCostToEmployer | number | |
statutoryWarnings | string[] | Stable codes such as MISSING_INCOME_TAX_NUMBER — these flag a filing, not an error in the figures. |
lineItems[].id | string | |
lineItems[].kind | string | "ALLOWANCE" · "DEDUCTION" · "REIMBURSEMENT". |
lineItems[].label / .amount / .category | mixed | |
lineItems[].pcbTaxableAmount | number or null | |
lineItems[].claimId | string or null | Set when the line came from an attached claim. |
lineItems[].subjectToEpf / .subjectToSocso / .subjectToEis / .subjectToPcb | boolean | Which wage bases this line fed, as the category defines it. |
Running payroll
"Run payroll": build every payslip on the run from the employees' current profiles. Safe to call repeatedly on a draft — each call discards the previous payslips and line items and rebuilds from scratch, folding in whatever adjustments and claims are on the run.
Response 200
| Field | Type | Meaning |
|---|---|---|
detail | object | The run detail shape (run + payslips + memberEmployeeProfileIds). |
payslipCount | number | |
skippedEmployees[].employeeProfileId | string | |
skippedEmployees[].name | string | |
skippedEmployees[].reason | string | e.g. "Archived" or "Not employed during this period" — not an error, a legitimate absence. |
Errors
404— no such run (empty body).409{ "error": "Payroll can only be run on a draft." }— a submitted month's figures are filed and never rebuilt.
curl -X POST https://<api-host>/payroll/runs/run_123/generate \
-H "Authorization: Bearer wp_live_xxx"
What the statutory files below still need. Submit refuses while this is
not ok — check it first rather than guessing from a 409.
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | |
totalMissingCount | number | |
orgIssues | string[] | Missing Company Info fields, e.g. "Employer LHDN E-number". |
employeeIssues[].name | string | |
employeeIssues[].employeeCode | string | |
employeeIssues[].missing | string[] | "Employee number", "IC number" or "Passport number". |
The income tax number is deliberately not checked here — PCB computes without one.
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/readiness \
-H "Authorization: Bearer wp_live_xxx"
Where this run's payslips disagree with a salary change that took effect part-way through the month. Advisory only — the engine pays one salary for the whole month, and whether a raise was meant to be backdated is a human decision; nothing here is applied automatically. Applying a hint means saving its suggested line through Save adjustment.
Response 200
An array of hints (empty when the run has no payslips yet, or no salary change fell inside the period).
| Field | Type | Meaning |
|---|---|---|
payslipId / employeeProfileId / employeeName | string | |
salaryChangeId | string | |
effectiveDate | string | ISO 8601. |
previousMonthlySalary / newMonthlySalary | number | |
reasonLabel | string | e.g. "Raise". |
payslipSnapshotMonthlySalary | number | What the payslip was actually generated against. |
outcome | string | "OVERPAID" (paid the new rate all month, pre-change days to claw back) · "UNDERPAID" (paid the old rate all month, post-change days owed) · "MATCHED" (effective on day one, nothing to do) · "UNKNOWN" (snapshot matches neither rate — no figure suggested). |
totalDaysInPeriod / daysAtOldRate / daysAtNewRate | number | |
delta | number | Always non-negative; the direction is in outcome. |
suggestedLineItem | object or null | { kind, category, label, amount } — the adjustment row to add. Null when there is nothing to do, or it was already applied. |
alreadyApplied | boolean | True when a line carrying this hint's marker is already on the run's adjustment. |
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/salary-change-hints \
-H "Authorization: Bearer wp_live_xxx"
Approval
DRAFT → PENDING_APPROVAL. Most of this endpoint's value is in what it refuses:
- The run has no payslips yet (press generate first).
- The run is
isStale— something changed after the last generation. - Any payslip's net pay is negative (exactly RM 0 is allowed).
- The previous calendar month has no run, or has a run that is not yet SUBMITTED — months must submit in order, because year-to-date figures compound off each filed month.
- Readiness is not
ok.
Response 200
The updated run object, now PENDING_APPROVAL.
Errors
404— no such run.409{ "error": "…" }— one of: already awaiting approval / already submitted / the guards above, each with its own message (e.g."Submit December 2025 first — payroll runs are submitted in order, and that month's run is still a draft."or"Cannot submit — fix these first: …").
curl -X POST https://<api-host>/payroll/runs/run_123/submit \
-H "Authorization: Bearer wp_live_xxx"
PENDING_APPROVAL → SUBMITTED, by a different actor than the one who submitted (both are recorded on the run). If the org has opted into automatic Xero posting, this also posts the run's journal — best effort: a Xero failure here never un-approves the run.
Response 200
The updated run object.
Errors
404— no such run.409{ "error": "Only a run awaiting approval can be approved." }
curl -X POST https://<api-host>/payroll/runs/run_123/approve \
-H "Authorization: Bearer wp_live_xxx"
PENDING_APPROVAL → DRAFT, with a reason for whoever submitted it.
Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
reason | string or null | no | Max 1000 characters. Stored on the run as approvalRejectionReason until the next submission. |
Response 200
The updated run object, back to DRAFT.
Errors
404— no such run.409{ "error": "Only a run awaiting approval can be sent back." }
curl -X POST https://<api-host>/payroll/runs/run_123/reject \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "reason": "SOCSO number missing for one employee" }'
SUBMITTED → DRAFT. Cascades: every later SUBMITTED month in the same year is reverted too, because their year-to-date figures were computed off this one. Check revert-impact first to see what else will move.
Response 200
| Field | Type | Meaning |
|---|---|---|
run | object | The run object that was asked for, reverted. |
alsoReverted | string[] | Period labels of the other months reverted with it, e.g. ["February 2026", "March 2026"]. Empty is the common case. |
Errors
404— no such run.409{ "error": "Only a submitted run can be reverted to draft." }
curl -X POST https://<api-host>/payroll/runs/run_123/revert \
-H "Authorization: Bearer wp_live_xxx"
What a revert of this run would also pull back to draft — for a confirmation step before calling revert.
Response 200
{ "alsoReverted": string[] } — period labels. Empty if the run is not SUBMITTED (not an error — there is simply nothing to cascade).
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/revert-impact \
-H "Authorization: Bearer wp_live_xxx"
Record LHDN's receipt numbers for this month's MTD and CP38 payments, printed on each employee's PCB 2(II). Replaces the whole set — every field is optional, and a blank one clears it (the payment may not have been made yet).
Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
pcbReceiptNo | string or null | no | Max 60 characters. |
pcbReceiptDate | string or null | no | ISO 8601 date. |
cp38ReceiptNo | string or null | no | Max 60 characters. |
cp38ReceiptDate | string or null | no | ISO 8601 date. |
Response 200
The updated run object.
Errors
404— no such run.409{ "error": "LHDN receipts can only be recorded on an approved run." }
curl -X PUT https://<api-host>/payroll/runs/run_123/lhdn-receipts \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "pcbReceiptNo": "CP39-000123", "pcbReceiptDate": "2026-02-10" }'
Adjustments
What an admin types for one employee on one run — overtime hours, one-off allowances or deductions, fixed-allowance overrides, worked/expected hours. These outlive a regeneration; the payslip line items they produce do not. Draft-only on every route below.
A spreadsheet pre-filled with the run's payable employees and whatever manual lines they already have, for bulk editing. Right for forty employees; the per-employee routes below are right for one or two.
Response 200
Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
filename payroll-adjustments-{year}-{month}.xlsx (e.g.
payroll-adjustments-2026-01.xlsx), Cache-Control: no-store. Two sheets:
Adjustments (columns: Full Name, Category, Label, Amount, Treat as recurring,
Current Salary — reference only, New Basic Salary, Salary Change Reason, Salary Effective Date,
Salary Change Notes) and a read-only Categories catalogue to copy a code from.
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/adjustments/template \
-H "Authorization: Bearer wp_live_xxx" \
-o adjustments.xlsx
Upload the filled-in template back (.xlsx or .csv,
multipart/form-data, field name file). All-or-nothing — on any row
error nothing is written.
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | true on a 200. |
employeesAffected | number | |
linesWritten | number | |
employeesCleared | number | Had manual lines, and the file said nothing about them. |
salaryChangesApplied | number | |
salarySkippedNotMonthly | string[] | Names of employees with a New Basic Salary that was ignored because they are paid hourly. |
Errors
400{ "error": "No file was uploaded." }— no file in the request.400{ "error": "The file has errors.", "errors": [{ "row": 4, "message": "…" }] }— one or more rows could not be parsed (unknown employee name, unknown category, negative amount, etc). Row numbers match the spreadsheet (header is row 1).404— no such run (empty body).409{ "error": "…" }— a whole-file problem: the run is not a draft, the file is empty or unreadable, a required column is missing, or two rows disagree on one employee's new salary.
curl -X POST https://<api-host>/payroll/runs/run_123/adjustments/import \
-H "Authorization: Bearer wp_live_xxx" \
-F "file=@adjustments.xlsx"
Every adjustment typed on this run (one per employee who has one).
Response 200
An array of adjustment objects.
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/adjustments \
-H "Authorization: Bearer wp_live_xxx"
One employee's adjustment on this run.
Response 200
Errors
404 — no such run, or no adjustment has been typed for this employee yet (there is no empty-object response; absence is a 404 either way).
curl https://<api-host>/payroll/runs/run_123/adjustments/emp_123 \
-H "Authorization: Bearer wp_live_xxx"
Everything an adjustment editor needs for one employee, in one read: what is already saved, their profile's fixed allowances, what attendance derived for the period, and what approved overtime would otherwise be paid.
Response 200
| Field | Type | Meaning |
|---|---|---|
employeeProfileId / employeeName | string | |
salaryType | string | "MONTHLY" or "HOURLY". |
adjustment | object or null | An adjustment object, or null if nothing is saved yet. |
fixedAllowances[].index | number | The key fixedAllowanceOverrides uses, as a string. |
fixedAllowances[].category / .name / .amount / .treatAsRecurring | mixed | The profile's own recurring row. |
autoWorkedHours / autoExpectedHours | number or null | Derived from attendance for the period; null when attendance does not apply (see attendanceApplies) rather than a confident zero. |
attendanceApplies | boolean | |
cashOvertime | boolean | False when the employee's policy banks overtime as time off, or disables it — typed OT hours are then ignored at generation. |
overtimeDisabledReason | string or null | Explains a false cashOvertime. |
approvedOtNormalHours / approvedOtRestHours / approvedOtPublicHours | number | What generation pays by default, before any typed override. |
loanInstallments[].loanId / .label / .amount | mixed | Read-only here — loans are managed elsewhere. |
editable | boolean | False once the run has left DRAFT. |
Errors
404 — no such run, or no such employee in this org.
curl https://<api-host>/payroll/runs/run_123/adjustments/emp_123/context \
-H "Authorization: Bearer wp_live_xxx"
Replaces the whole row. This is a full save, not a patch: send every field you want to keep, including manual line items already on it — omitting one clears it. A partial update would make "I cleared the overtime" indistinguishable from "I did not mention it".
Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
otNormalHours / otRestHours / otPublicHours | number | no (default 0) | 0–999.99. |
manualLineItems[].category | string | yes (per row) | Max 60 chars. A code from the Categories sheet / GET /payroll/runs/{id}/adjustments/template's Categories sheet. Unrecognised → 400. |
manualLineItems[].label | string or null | no | Max 200 chars. |
manualLineItems[].amount | number | yes (per row) | 0–99999999.99. Always non-negative — the category already decides whether it adds or deducts. |
manualLineItems[].treatAsRecurring | boolean | no | Only meaningful on an additional-remuneration category. |
fixedAllowanceOverrides | object | no | Keyed by the profile fixed-allowance's array index as a string, e.g. { "0": { "amount": 500, "skip": false } }. amount: null keeps the profile's figure; skip: true zeroes that row out for this run only. |
workedHours / expectedHours | number or null | no | 0–9999.99. Overrides what attendance derived. |
notes | string or null | no | Max 2000 chars. |
Response 200
The saved adjustment object. Saving marks the run isStale.
Errors
400{ "error": "Unknown adjustment category 'xyz'." }404— no such run.409{ "error": "This run is no longer a draft — revert it before editing adjustments." }
curl -X PUT https://<api-host>/payroll/runs/run_123/adjustments/emp_123 \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"otNormalHours": 4,
"otRestHours": 0,
"otPublicHours": 0,
"manualLineItems": [
{ "category": "allowance_travel", "label": "February site travel", "amount": 250.00, "treatAsRecurring": false }
],
"fixedAllowanceOverrides": {}
}'
Remove the whole adjustment row for this employee on this run.
Response
204 No Content — whether or not there was an adjustment to remove. The run is only marked isStale when something actually existed to clear.
Errors
404— no such run.409{ "error": "This run is no longer a draft — revert it before clearing adjustments." }
curl -X DELETE https://<api-host>/payroll/runs/run_123/adjustments/emp_123 \
-H "Authorization: Bearer wp_live_xxx"
The adjustment object
| Field | Type | Meaning |
|---|---|---|
id / payrollRunId / employeeProfileId | string | |
otNormalHours / otRestHours / otPublicHours | number | |
manualLineItems[].kind | string | "ALLOWANCE" · "DEDUCTION" · "REIMBURSEMENT" — derived from the category, never trusted from input. |
manualLineItems[].category / .label / .amount / .treatAsRecurring | mixed | |
fixedAllowanceOverrides | object | Same shape as the request. |
workedHours / expectedHours | number or null | |
notes | string or null | |
createdAt / updatedAt | string | ISO 8601. |
Attached claims
Approved, personal-paid expense claims reimbursed through an employee's pay instead of through Xero. The attachment is durable; the reimbursement line item it produces on the payslip is rebuilt on every generation.
Claims currently attached to this run.
Response 200
| Field | Type | Meaning |
|---|---|---|
id / payrollRunId / claimId / employeeProfileId | string | |
label / amount | string / number | Snapshotted at attach time — editing the claim afterwards does not move these. |
employeeName / employeeNumber / claimNumber | string | |
createdAt | string | ISO 8601. |
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/claims \
-H "Authorization: Bearer wp_live_xxx"
Claims that could go on any run — approved, personal-paid, and routed to payroll
settlement — not scoped to this one. The {id} is only used to confirm the run
exists; rows already attached elsewhere are included and flagged rather than omitted.
Response 200
| Field | Type | Meaning |
|---|---|---|
claimId / claimNumber / title | string | |
category / claimType | string | Claim enums. |
amount / spentAt | number / string | ISO 8601 date. |
userId / employeeProfileId / employeeName / employeeNumber | string | |
attachedToRunId / attachedToRunPeriod | string or null | Null means free to attach. |
blockedReason | string or null | Why it cannot be attached right now (e.g. the claimant has no payroll profile). Null means it can. |
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/claims/attachable \
-H "Authorization: Bearer wp_live_xxx"
Attach one approved claim to a draft run.
Request body
| Field | Type | Required | Meaning |
|---|---|---|---|
claimId | string | yes | Max 40 chars. |
Response 200
The created claim-attachment object (see above).
Errors
404— no such run.409{ "error": "…" }— one of: the run is no longer a draft; the claim id does not resolve in this org; it is already attached to another run (names which one); or it is blocked (e.g. the claimant has no payroll profile).
curl -X POST https://<api-host>/payroll/runs/run_123/claims \
-H "Authorization: Bearer wp_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "claimId": "claim_123" }'
Detach a claim. It becomes free to attach elsewhere; the claim itself is untouched.
Response
204 No Content.
Errors
404— this claim id is not currently attached to this run (whether it isn't attached to anything, or it's attached to a different run).409{ "error": "This run is no longer a draft — revert it before detaching reimbursements." }— this run is not a draft.
curl -X DELETE https://<api-host>/payroll/runs/run_123/claims/claim_123 \
-H "Authorization: Bearer wp_live_xxx"
Statutory files
The monthly submissions, generated on demand from the run's payslips — those are the filed figures and never move. Identifiers (EPF number, IC, etc.) are read live from the employee's current profile, so a corrected typo reaches the next download.
SUBMITTED and
source: "COMPUTED" (not "IMPORTED"). Otherwise:
409 { "error": "This run has not been approved yet, so its figures can still change. Submit and approve it before producing files." }
or, for an imported run, a message explaining the previous system already filed it. Both are
omitted below to avoid repetition.
EPF contribution CSV for KWSP i-Akaun Majikan bulk upload. Rows with no EPF number, or nothing to remit, are omitted.
Response 200
Content-Type: text/csv, filename {ddMMyyyy}-EPF_iAkaun-{yyyy}_{mm}.csv (date = today, UTC) — e.g. 01102026-EPF_iAkaun-2026_01.csv.
Errors
404 — no such run (empty body).
curl https://<api-host>/payroll/runs/run_123/files/epf \
-H "Authorization: Bearer wp_live_xxx" -o epf.csv
SOCSO + EIS combined contribution TXT, PERKESO ASSIST spec v1.0, fixed-width.
Response 200
Content-Type: text/plain, filename SOCSO_EIS_{mm}{yyyy}.txt.
Errors
404— no such run.409{ "error": "…" }— PERKESO Employer Code missing or looks like a placeholder.
curl https://<api-host>/payroll/runs/run_123/files/socso-eis \
-H "Authorization: Bearer wp_live_xxx" -o socso_eis.txt
The same SOCSO + EIS submission, in ASSIST 2.0's layout with SKBBK included. Admins pick whichever layout their PERKESO portal account is on — this is not a different period's data.
Response 200
Content-Type: text/plain, filename SOCSO_EIS_SKBBK_{mm}{yyyy}.txt.
Errors
Same as above.
curl https://<api-host>/payroll/runs/run_123/files/socso-eis-skbbk \
-H "Authorization: Bearer wp_live_xxx" -o socso_eis_skbbk.txt
LHDN's CP39 PCB/MTD batch submission TXT, fixed-width.
Response 200
Content-Type: text/plain, filename {10-digit employer no}{mm}_{yyyy}.txt.
Errors
404— no such run.409{ "error": "…" }— Employer LHDN E-number missing or looks like a placeholder.
curl https://<api-host>/payroll/runs/run_123/files/pcb \
-H "Authorization: Bearer wp_live_xxx" -o pcb.txt
Documents
One employee's payslip PDF. The one document a draft may also produce — as a
watermarked preview, so figures can be checked before approval. Everything else in this section
still waits for SUBMITTED.
Response 200
Content-Type: application/pdf, filename
{employeeNumber}_{name}_{mm-yyyy}.pdf, with a _DRAFT suffix before
.pdf when the run is not yet submitted.
Errors
404— no such run.409{ "error": "That employee has no payslip on this run." }— not generated yet, or not a member of this run.
curl https://<api-host>/payroll/runs/run_123/documents/payslip/emp_123 \
-H "Authorization: Bearer wp_live_xxx" -o payslip.pdf
Every payslip on the run, as a ZIP of individual PDFs (one per employee) — finance teams forward them one at a time.
Response 200
Content-Type: application/zip, filename Payslips_{yyyy}_{mm}_All.zip.
Errors
404— no such run.409— run not submitted, or no payslips on the run.
curl https://<api-host>/payroll/runs/run_123/documents/payslips \
-H "Authorization: Bearer wp_live_xxx" -o payslips.zip
The whole run on one sheet: gross, every statutory deduction and net per employee, with run totals.
Response 200
Content-Type: application/pdf, filename Payroll_Summary_{Month}_{Year}.pdf (e.g. Payroll_Summary_January_2026.pdf).
Errors
404 — no such run. 409 — run not submitted/imported, or no payslips.
curl https://<api-host>/payroll/runs/run_123/documents/summary \
-H "Authorization: Bearer wp_live_xxx" -o summary.pdf
Who is paid what, into which account — in full, unmasked (unlike a payslip). For an approver to check against the bank file before releasing it.
Response 200
Content-Type: application/pdf, filename Payment_Schedule_{Month}_{Year}.pdf.
Errors
404 — no such run. 409 — run not submitted/imported.
curl https://<api-host>/payroll/runs/run_123/documents/payment-schedule \
-H "Authorization: Bearer wp_live_xxx" -o payment-schedule.pdf
LHDN's MTD §E worksheet, one page per employee, re-deriving the PCB calculation by hand. Reads each payslip's stored breakdown rather than recomputing it, so a filed figure never moves.
Response 200
Content-Type: application/pdf, filename PCB_Calculation_Details_{Month}_{Year}.pdf.
Errors
404 — no such run. 409 — run not submitted/imported, or no payslips.
curl https://<api-host>/payroll/runs/run_123/documents/pcb-details \
-H "Authorization: Bearer wp_live_xxx" -o pcb-details.pdf
Borang PCB/TP1 for one employee: each relief item's limit, this month's figure (SEMASA) and the year-to-date (TERKUMPUL), as LHDN's form lays it out.
Response 200
Content-Type: application/pdf, filename TP1_{employeeNumber}_{mm}-{yyyy}.pdf.
Errors
404— no such run.409{ "error": "…" }— run not submitted, or that employee has no payslip on this run.
curl https://<api-host>/payroll/runs/run_123/documents/tp1/emp_123 \
-H "Authorization: Bearer wp_live_xxx" -o tp1.pdf
The month's printable list of employees who claimed a TP1 relief or declared a previous employer (TP3), as the MTD spec requires.
Response 200
Content-Type: application/pdf, filename TP1_TP3_Claims_{mm}-{yyyy}.pdf.
Errors
404 — no such run. 409 — run not submitted.
curl https://<api-host>/payroll/runs/run_123/documents/tp1-claims \
-H "Authorization: Bearer wp_live_xxx" -o tp1-claims.pdf
The bank disbursement file, in the layout of the company's own payroll bank (set in Payroll Settings → Company Info).
Query parameters
| Param | Type | Meaning |
|---|---|---|
paymentDate | string, optional | ISO 8601 date — the value date. Defaults to the last day of the payroll period. |
recipientReference | string, optional | Hong Leong only; every other bank ignores it. |
channel | string, optional | Hong Leong only: "ConnectFirst" or "ConnectBiz" — the two upload portals take different files, so this is required for an HLB org. |
Response 200
Content type and filename depend on the configured bank (e.g. Public Bank's ECP spreadsheet, Maybank/CIMB TXT, or Hong Leong's two formats).
Errors
404— no such run.409{ "error": "…" }— run not submitted/imported; no payroll bank configured or it is set to "Other"; an unsupported bank; or Hong Leong with nochannel.
curl "https://<api-host>/payroll/runs/run_123/documents/bank-file?paymentDate=2026-01-31" \
-H "Authorization: Bearer wp_live_xxx" -o bank-file
Every document this run produces, zipped: the bank file, the summary, all payslips, and the three statutory files. One request instead of six. A document that cannot be rendered (e.g. no payroll bank configured) is left out of the zip rather than failing the whole bundle.
Query parameters
| Param | Type | Meaning |
|---|---|---|
paymentDate | string, optional | ISO 8601 date, passed through to the bank file. |
Response 200
Content-Type: application/zip, filename payroll-{yyyy}-{mm}.zip. Two extra headers:
| Header | Meaning |
|---|---|
X-Bundle-File-Count | How many documents made it into the zip. |
X-Bundle-Skipped | Comma-separated keys of documents left out, from: bank-file, summary, payslips, epf, socso-eis, socso-eis-skbbk, pcb. Omitted entirely when nothing was skipped. |
Check these headers rather than unzipping the file to tell "no bank file configured" from "the zip is fine".
Errors
404— no such run.409{ "error": "…" }— the run itself is not submitted (an individual missing document inside the zip does not fail the request).
curl https://<api-host>/payroll/runs/run_123/download \
-H "Authorization: Bearer wp_live_xxx" -D - -o payroll-bundle.zip
Emailing payslips
Manual, admin-triggered — never automatic, so there is no accidental blast. Both routes send a real email to the employee's address on file when they succeed.
Email one employee their payslip PDF for this run.
Response 200
{ "ok": true, "error": null }
Errors
404{ "error": "…" }— no such run, or no payslip for that employee on this run.409{ "error": "…" }— the run exists but can't be emailed yet: notSUBMITTED, or no email address on file for that employee.
curl -X POST https://<api-host>/payroll/runs/run_123/documents/payslip/emp_123/email \
-H "Authorization: Bearer wp_live_xxx"
Email every payslip on this run to its employee, one send at a time.
Response 200
| Field | Type | Meaning |
|---|---|---|
sent | number | |
failed[].employeeName / .reason | string | Per-employee failures (e.g. no email on file) do not fail the request — this is still a 200. |
error | string or null | Set only for a whole-run failure. |
Errors
404{ "error": "…" }— no such run.409{ "error": "…" }— the run exists but isn'tSUBMITTEDyet.
curl -X POST https://<api-host>/payroll/runs/run_123/email-payslips \
-H "Authorization: Bearer wp_live_xxx"
Xero
The org's Xero tracking categories, for mapping payroll accounts to them. Not run-scoped — lives on this controller alongside the rest of the payroll-to-Xero surface.
Response 200
An array: { "trackingCategoryId": string, "name": string, "options": string[] }. Empty when Xero is not connected or is unreachable.
curl https://<api-host>/payroll/runs/xero/tracking-categories \
-H "Authorization: Bearer wp_live_xxx"
The manual journal that WOULD post, so it can be checked against the chart of accounts before committing. Writes nothing.
Response 200
| Field | Type | Meaning |
|---|---|---|
ok | boolean | Whether these lines would actually post — check this, not the HTTP status: a mapping problem still answers 200. |
error | string or null | Why ok is false, e.g. an unmapped account. |
narration / date | string | |
lines[].accountCode / .description / .amount / .trackingOption | mixed | |
balance | number | Sum of every line's amount — should be 0 for a balanced journal. |
totalDebits / totalCredits | number | |
xeroManualJournalId | string or null | Non-null once the run has already posted. |
status | string | "NOT_SYNCED" · "SYNCED" · "ERROR". |
Errors
404 — no such run.
curl https://<api-host>/payroll/runs/run_123/xero/preview \
-H "Authorization: Bearer wp_live_xxx"
Post the run's journal to Xero. Idempotent: calling it again after a success reports the journal already there rather than creating a second one.
Response 200
| Field | Type | Meaning |
|---|---|---|
found | boolean | true on a 200. |
ok | boolean | true on a 200 (a failed post is returned as a 409 instead — see below). |
manualJournalId | string or null | |
lineCount | number | 0 when alreadyPosted is true — the existing journal's lines are not recounted. |
alreadyPosted | boolean |
Errors
404— no such run (empty body).409{ "error": "…" }— note the body here is just{ "error" }, not the full shape above: notSUBMITTEDyet, the org is not connected to Xero, an unmapped account, or an unbalanced journal.
curl -X POST https://<api-host>/payroll/runs/run_123/xero/sync \
-H "Authorization: Bearer wp_live_xxx"
Not available to API keys
Every action on PayrollRunsController is reachable with a scoped API key — none are
marked [HumanOnly]. All of them also require the Payroll module on
the org's plan.