Developer docs
Payroll runs
REST · JSON · API keys

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_APPROVAL or SUBMITTED those writes answer 409.
  • 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/runs for a month that already has a run answers 409 — open the existing run instead of retrying.
  • Two employee ids. Every id on this page is an employeeProfileId from GET /payroll/employees or the picker below — not the user id GET /employees returns. Passing the wrong one reads back as "not found".
  • Generation is destructive. Pressing POST /{id}/generate discards 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 isStale field is true when 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.
Writes here move money and file paperwork with Malaysian authorities. Grant payroll:write only to a system that genuinely needs to run payroll, not just to read it.

Runs

GET /payroll/runs payroll:read

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"
GET /payroll/runs/picker payroll:read

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

FieldTypeMeaning
policies[].idstringPolicy id.
policies[].namestring
policies[].isDefaultboolean
policies[].members[].employeeProfileIdstringWhat create and every adjustment/claim route take as the employee id.
policies[].members[].namestring
policies[].members[].employeeIdstringThe org's own employee number.
policies[].members[].jobTitlestring
curl https://<api-host>/payroll/runs/picker \
  -H "Authorization: Bearer wp_live_xxx"
GET /payroll/runs/{id} payroll:read

One run with every payslip it has generated.

Response 200

FieldTypeMeaning
runobjectA run object.
payslipspayslip[]Empty until generate has run.
memberEmployeeProfileIdsstring[]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"
POST /payroll/runs payroll:write

Start a DRAFT run for a period. The id, status, totals and timestamps are the server's to set.

Request body

FieldTypeRequiredMeaning
periodYearnumberyes2000–2100.
periodMonthnumberyes1–12.
policyIdsstring[] or nullnoPolicy ids from the picker. Null or omitted means every policy — the whole eligible roster.
excludedEmployeeProfileIdsstring[] or nullnoIndividual 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 /payroll/runs/{id} payroll:write

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

FieldTypeMeaning
idstring
periodYear / periodMonthnumber
periodLabelstringe.g. "January 2026", rendered server-side.
statusstring"DRAFT" · "PENDING_APPROVAL" · "SUBMITTED".
sourcestring"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).
employeeCountnumber
totalGross / totalNetnumberMYR, run totals.
totalEmployeeEpf / totalEmployerEpfnumber
totalEmployeeSocso / totalEmployerSocsonumber
totalEmployeeEis / totalEmployerEisnumber
totalEmployeeSkbbknumber
totalPcb / totalCp38 / totalZakatnumber
totalHrdfnumber
employeesSubjectToHrdfnumber
totalWagesSubjectToHrdfnumber
totalCostToEmployernumber
generatedAtstring or nullISO 8601. Null until generate has run.
submittedForApprovalAt / submittedForApprovalByIdstring or nullWho proposed the month, and when.
submittedAt / submittedByIdstring or nullWho approved it, and when — a different person from the above by design.
approvalRejectionReasonstring or nullSet by reject, cleared on the next submission.
isStalebooleanTrue only for a DRAFT whose inputs changed since it was last generated. See the lifecycle note above.
pcbReceiptNo / pcbReceiptDatestring / string or nullSet by LHDN receipts.
cp38ReceiptNo / cp38ReceiptDatestring / string or null
createdAt / updatedAtstringISO 8601.

The payslip object

One per employee on a run, returned inside run detail and generate's response.

FieldTypeMeaning
id / employeeProfileId / userIdstringuserId is the /employees id — not what the rest of this page takes.
snapshotName / snapshotEmployeeNumber / snapshotPosition / snapshotNationality / snapshotIsResidentmixedIdentity as it was AT GENERATION — a later rename does not move a filed payslip.
snapshotSalaryTypestring"MONTHLY" or "HOURLY".
snapshotMonthlySalary / snapshotHourlyRatenumber or null
totalWorkingDays / proratedDays / prorationDaysInPeriod / proratedFactornumberThree separate day figures on purpose — the ordinary-rate basis and the proration divisor are not the same thing.
workedHours / expectedHoursnumber or nullDisplay-only for MONTHLY staff; for HOURLY staff workedHours is the paid quantity.
unpaidLeaveDaysnumber or null
basicPay / proratedPaynumber
otNormalHours / otRestHours / otPublicHours / otPaynumber
totalAllowances / totalReimbursements / totalDeductions / totalBenefitsInKindnumber
epfEmployee / epfEmployernumber
socsoEmployee / socsoEmployernumber
eisEmployee / eisEmployernumber
skbbkEmployee / skbbkWagenumber
pcb / pcbNormal / pcbAdditionalnumberpcb is what was actually withheld; the PCB details document explains the breakdown behind it.
pcbCalculationJsonstring or nullThe raw LHDN-form decomposition, as stored — JSON-encoded text, not a nested object. Null only on payslips generated before this field existed.
cp38numberCourt-ordered arrears instalment, filed separately from PCB.
voluntaryPcbnumber"Additional PCB" — included in the CP39 file's PCB field, but not in pcb itself.
zakatnumber
hrdf / hrdfWagenumberEmployer-only; not deducted from the employee.
grossPay / netPay / totalCostToEmployernumber
statutoryWarningsstring[]Stable codes such as MISSING_INCOME_TAX_NUMBER — these flag a filing, not an error in the figures.
lineItems[].idstring
lineItems[].kindstring"ALLOWANCE" · "DEDUCTION" · "REIMBURSEMENT".
lineItems[].label / .amount / .categorymixed
lineItems[].pcbTaxableAmountnumber or null
lineItems[].claimIdstring or nullSet when the line came from an attached claim.
lineItems[].subjectToEpf / .subjectToSocso / .subjectToEis / .subjectToPcbbooleanWhich wage bases this line fed, as the category defines it.

Running payroll

POST /payroll/runs/{id}/generate payroll:write

"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

FieldTypeMeaning
detailobjectThe run detail shape (run + payslips + memberEmployeeProfileIds).
payslipCountnumber
skippedEmployees[].employeeProfileIdstring
skippedEmployees[].namestring
skippedEmployees[].reasonstringe.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"
GET /payroll/runs/{id}/readiness payroll:read

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

FieldTypeMeaning
okboolean
totalMissingCountnumber
orgIssuesstring[]Missing Company Info fields, e.g. "Employer LHDN E-number".
employeeIssues[].namestring
employeeIssues[].employeeCodestring
employeeIssues[].missingstring[]"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"
GET /payroll/runs/{id}/salary-change-hints payroll:read

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

FieldTypeMeaning
payslipId / employeeProfileId / employeeNamestring
salaryChangeIdstring
effectiveDatestringISO 8601.
previousMonthlySalary / newMonthlySalarynumber
reasonLabelstringe.g. "Raise".
payslipSnapshotMonthlySalarynumberWhat the payslip was actually generated against.
outcomestring"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 / daysAtNewRatenumber
deltanumberAlways non-negative; the direction is in outcome.
suggestedLineItemobject or null{ kind, category, label, amount } — the adjustment row to add. Null when there is nothing to do, or it was already applied.
alreadyAppliedbooleanTrue 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

POST /payroll/runs/{id}/submit payroll:write

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"
POST /payroll/runs/{id}/approve payroll:write

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"
POST /payroll/runs/{id}/reject payroll:write

PENDING_APPROVAL → DRAFT, with a reason for whoever submitted it.

Request body

FieldTypeRequiredMeaning
reasonstring or nullnoMax 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" }'
POST /payroll/runs/{id}/revert payroll:write

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

FieldTypeMeaning
runobjectThe run object that was asked for, reverted.
alsoRevertedstring[]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"
GET /payroll/runs/{id}/revert-impact payroll:read

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"
PUT /payroll/runs/{id}/lhdn-receipts payroll:write

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

FieldTypeRequiredMeaning
pcbReceiptNostring or nullnoMax 60 characters.
pcbReceiptDatestring or nullnoISO 8601 date.
cp38ReceiptNostring or nullnoMax 60 characters.
cp38ReceiptDatestring or nullnoISO 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.

GET /payroll/runs/{id}/adjustments/template payroll:read

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
POST /payroll/runs/{id}/adjustments/import payroll:write

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.

The Adjustments sheet's manual lines use replace semantics across the whole run: an employee the file says nothing about has their existing manual lines deleted, which is why the template arrives pre-filled. The salary columns are the opposite — blank means "leave this salary alone".

Response 200

FieldTypeMeaning
okbooleantrue on a 200.
employeesAffectednumber
linesWrittennumber
employeesClearednumberHad manual lines, and the file said nothing about them.
salaryChangesAppliednumber
salarySkippedNotMonthlystring[]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"
GET /payroll/runs/{id}/adjustments payroll:read

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"
GET /payroll/runs/{id}/adjustments/{employeeProfileId} payroll:read

One employee's adjustment on this run.

Response 200

An adjustment object.

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"
GET /payroll/runs/{id}/adjustments/{employeeProfileId}/context payroll:read

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

FieldTypeMeaning
employeeProfileId / employeeNamestring
salaryTypestring"MONTHLY" or "HOURLY".
adjustmentobject or nullAn adjustment object, or null if nothing is saved yet.
fixedAllowances[].indexnumberThe key fixedAllowanceOverrides uses, as a string.
fixedAllowances[].category / .name / .amount / .treatAsRecurringmixedThe profile's own recurring row.
autoWorkedHours / autoExpectedHoursnumber or nullDerived from attendance for the period; null when attendance does not apply (see attendanceApplies) rather than a confident zero.
attendanceAppliesboolean
cashOvertimebooleanFalse when the employee's policy banks overtime as time off, or disables it — typed OT hours are then ignored at generation.
overtimeDisabledReasonstring or nullExplains a false cashOvertime.
approvedOtNormalHours / approvedOtRestHours / approvedOtPublicHoursnumberWhat generation pays by default, before any typed override.
loanInstallments[].loanId / .label / .amountmixedRead-only here — loans are managed elsewhere.
editablebooleanFalse 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"
PUT /payroll/runs/{id}/adjustments/{employeeProfileId} payroll:write

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

FieldTypeRequiredMeaning
otNormalHours / otRestHours / otPublicHoursnumberno (default 0)0–999.99.
manualLineItems[].categorystringyes (per row)Max 60 chars. A code from the Categories sheet / GET /payroll/runs/{id}/adjustments/template's Categories sheet. Unrecognised → 400.
manualLineItems[].labelstring or nullnoMax 200 chars.
manualLineItems[].amountnumberyes (per row)0–99999999.99. Always non-negative — the category already decides whether it adds or deducts.
manualLineItems[].treatAsRecurringbooleannoOnly meaningful on an additional-remuneration category.
fixedAllowanceOverridesobjectnoKeyed 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 / expectedHoursnumber or nullno0–9999.99. Overrides what attendance derived.
notesstring or nullnoMax 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": {}
  }'
DELETE /payroll/runs/{id}/adjustments/{employeeProfileId} payroll:write

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

FieldTypeMeaning
id / payrollRunId / employeeProfileIdstring
otNormalHours / otRestHours / otPublicHoursnumber
manualLineItems[].kindstring"ALLOWANCE" · "DEDUCTION" · "REIMBURSEMENT" — derived from the category, never trusted from input.
manualLineItems[].category / .label / .amount / .treatAsRecurringmixed
fixedAllowanceOverridesobjectSame shape as the request.
workedHours / expectedHoursnumber or null
notesstring or null
createdAt / updatedAtstringISO 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.

GET /payroll/runs/{id}/claims payroll:read

Claims currently attached to this run.

Response 200

FieldTypeMeaning
id / payrollRunId / claimId / employeeProfileIdstring
label / amountstring / numberSnapshotted at attach time — editing the claim afterwards does not move these.
employeeName / employeeNumber / claimNumberstring
createdAtstringISO 8601.

Errors

404 — no such run.

curl https://<api-host>/payroll/runs/run_123/claims \
  -H "Authorization: Bearer wp_live_xxx"
GET /payroll/runs/{id}/claims/attachable payroll:read

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

FieldTypeMeaning
claimId / claimNumber / titlestring
category / claimTypestringClaim enums.
amount / spentAtnumber / stringISO 8601 date.
userId / employeeProfileId / employeeName / employeeNumberstring
attachedToRunId / attachedToRunPeriodstring or nullNull means free to attach.
blockedReasonstring or nullWhy 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"
POST /payroll/runs/{id}/claims payroll:write

Attach one approved claim to a draft run.

Request body

FieldTypeRequiredMeaning
claimIdstringyesMax 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" }'
DELETE /payroll/runs/{id}/claims/{claimId} payroll:write

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.

Every route in this section requires the run to be 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.
GET /payroll/runs/{id}/files/epf payroll:read

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
GET /payroll/runs/{id}/files/socso-eis payroll:read

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
GET /payroll/runs/{id}/files/socso-eis-skbbk payroll:read

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
GET /payroll/runs/{id}/files/pcb payroll:read

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

GET /payroll/runs/{id}/documents/payslip/{employeeProfileId} payroll:read

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
GET /payroll/runs/{id}/documents/payslips payroll:read

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
GET /payroll/runs/{id}/documents/summary payroll:read

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
GET /payroll/runs/{id}/documents/payment-schedule payroll:read

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
GET /payroll/runs/{id}/documents/pcb-details payroll:read

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
GET /payroll/runs/{id}/documents/tp1/{employeeProfileId} payroll:read

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
GET /payroll/runs/{id}/documents/tp1-claims payroll:read

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
GET /payroll/runs/{id}/documents/bank-file payroll:read

The bank disbursement file, in the layout of the company's own payroll bank (set in Payroll Settings → Company Info).

Query parameters

ParamTypeMeaning
paymentDatestring, optionalISO 8601 date — the value date. Defaults to the last day of the payroll period.
recipientReferencestring, optionalHong Leong only; every other bank ignores it.
channelstring, optionalHong 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 no channel.
curl "https://<api-host>/payroll/runs/run_123/documents/bank-file?paymentDate=2026-01-31" \
  -H "Authorization: Bearer wp_live_xxx" -o bank-file
GET /payroll/runs/{id}/documents/download alias: /payroll/runs/{id}/download payroll:read

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

ParamTypeMeaning
paymentDatestring, optionalISO 8601 date, passed through to the bank file.

Response 200

Content-Type: application/zip, filename payroll-{yyyy}-{mm}.zip. Two extra headers:

HeaderMeaning
X-Bundle-File-CountHow many documents made it into the zip.
X-Bundle-SkippedComma-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.

POST /payroll/runs/{id}/documents/payslip/{employeeProfileId}/email payroll:write

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: not SUBMITTED, 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"
POST /payroll/runs/{id}/email-payslips payroll:write

Email every payslip on this run to its employee, one send at a time.

Response 200

FieldTypeMeaning
sentnumber
failed[].employeeName / .reasonstringPer-employee failures (e.g. no email on file) do not fail the request — this is still a 200.
errorstring or nullSet only for a whole-run failure.

Errors

  • 404 { "error": "…" } — no such run.
  • 409 { "error": "…" } — the run exists but isn't SUBMITTED yet.
curl -X POST https://<api-host>/payroll/runs/run_123/email-payslips \
  -H "Authorization: Bearer wp_live_xxx"

Xero

GET /payroll/runs/xero/tracking-categories payroll:read

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"
GET /payroll/runs/{id}/xero/preview payroll:read

The manual journal that WOULD post, so it can be checked against the chart of accounts before committing. Writes nothing.

Response 200

FieldTypeMeaning
okbooleanWhether these lines would actually post — check this, not the HTTP status: a mapping problem still answers 200.
errorstring or nullWhy ok is false, e.g. an unmapped account.
narration / datestring
lines[].accountCode / .description / .amount / .trackingOptionmixed
balancenumberSum of every line's amount — should be 0 for a balanced journal.
totalDebits / totalCreditsnumber
xeroManualJournalIdstring or nullNon-null once the run has already posted.
statusstring"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 /payroll/runs/{id}/xero/sync payroll:write

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

FieldTypeMeaning
foundbooleantrue on a 200.
okbooleantrue on a 200 (a failed post is returned as a 409 instead — see below).
manualJournalIdstring or null
lineCountnumber0 when alreadyPosted is true — the existing journal's lines are not recounted.
alreadyPostedboolean

Errors

  • 404 — no such run (empty body).
  • 409 { "error": "…" } — note the body here is just { "error" }, not the full shape above: not SUBMITTED yet, 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.