00Who this is for
You are building software that manages people on someone else's payroll. Your customer runs Lightning Payroll, you hold an OAuth client, and you want your system to be the place an employee is hired, changed and ended, with Lightning Payroll kept in step automatically.
Everything here happens on the /api surface with a bearer token. There is one mental model to hold: an employee belongs to a company, a company belongs to your customer, and your token is scoped to that customer's data. You create the employee, you configure the pieces that make them payable, you keep them current, and one day you terminate them. Lightning Payroll turns that into compliant payroll: a final pay, the right leave payout, the right tax, and the right thing reported to the tax office.
What this guide covers
- Creating employees, including exactly which fields are mandatory in Australia and in New Zealand, and why that differs.
- The sub-resources that turn a record into a payable employee: bank accounts, superannuation or KiwiSaver, allowances.
- Reading and updating employees, and the identifiers you can key on.
- Termination, in both countries, including manual payout amounts and the figures that come back.
- Reinstatement, soft deletion and restoration, and which of them you actually want.
- The webhooks that tell you an employee changed, and how to reconcile if you miss one.
What it does not cover
Authentication is a guide of its own: see the OAuth Authentication Guide for the authorization code flow, token lifetimes and the scope catalogue, and the API Admin Setup and Management Guide for getting an API admin account and an OAuth client in the first place. Buying subscriptions on a customer's behalf is Partner Checkout. Registering webhook endpoints and rotating their secrets is the Webhooks Guide; this guide only covers the employee events themselves. Pushing hours in for a pay run is the timesheet integration guide.
The interactive schema at /openapi.json is generated from the running code and is authoritative. Where this guide and the schema disagree, the schema is right and we would like to know: the guide is the explanation, not the contract.
01The lifecycle model
Four operations, two independent axes. Almost every integration bug in this area comes from treating them as one axis.
Employment state answers "does this person still work here". It moves with /terminate and /reinstate. A terminated employee is a former employee: they have a termination date, a final pay, and a cessation reported to the tax office. They stay fully visible, because payroll history does not disappear when someone leaves.
Record visibility answers "should this record show up in the payroll app". It moves with DELETE and /restore. A deleted employee is hidden from the day-to-day screens, and their pays, payslips and payroll number are all still there. Nothing is destroyed and nothing is reported.
| Operation | Call | What it changes | Use it when |
|---|---|---|---|
| Terminate | POST/api/employees/{id}/terminate | Sets termination_date and is_terminated, creates and processes a final pay carrying the leave payout and its tax. |
Someone actually left. This is the only operation that produces a compliant final pay. |
| Reinstate | POST/api/employees/{id}/reinstate | Clears is_terminated and termination_date, sets a new start_date. Year-to-date figures, payroll number and the old termination record are all kept. |
A rehire, or a termination that should not have happened. |
| Delete | DELETE/api/employees/{id} | Sets is_deleted. The record leaves the normal lists and keeps its pays, its history and its payroll number. |
The record should never have existed: a duplicate, a test row, a candidate who never started. |
| Restore | POST/api/employees/{id}/restore | Clears is_deleted. Employment state is untouched, so a terminated employee restores as terminated. |
Undoing a delete. Re-checks the customer's employee limit, so it can fail with 403 if they are now at their cap. |
Never end employment with a delete. Deleting hides a record; it pays nothing, it calculates no leave payout, and it reports no cessation. An employee who left and was deleted instead of terminated is an unpaid entitlement and a reporting gap, and it is your customer who wears both. If someone left, call /terminate. If you meant to hide a record that should never have existed, delete it.
The two axes compose, so a record can be terminated and deleted at once. Order does not matter, and undoing one does not undo the other. If you want a terminated employee gone from the customer's screens, terminate first so the final pay is right, then delete.
02Before you start
Five things worth knowing before your first write: how your token is scoped, how you address a company, what the rate limits are, what an error looks like, and why retries need your attention here.
Scopes
Every call on this surface needs a bearer token, and the token's scope decides what you can do. There is no separate employee or termination scope: payroll write covers all of it, terminations included.
| Scope | Grants |
|---|---|
payroll.read | Every GET in this guide: reading employees, bank accounts, super funds, allowances, terminations. |
payroll.write | Every other method: create, update, delete, restore, reinstate and terminate. |
A token carrying only checkout scopes cannot touch employees at all, and a token carrying payroll.write can terminate anyone on any company it can see. Scope your OAuth client to what your product actually does.
Scope is not the only gate. The four lifecycle operations (terminate, reinstate, delete and restore) also require the customer to hold an active subscription, and return 403 with "This customer has no active subscriptions and cannot update data." when it has lapsed. Creating an employee fails for a lapsed customer too, though by a different route: their employee allowance drops to zero, so the create is refused as a limit. Reads keep working throughout. If your integration suddenly starts getting 403s on a customer who worked yesterday, check their subscription before you check your token.
Addressing a company
Company-scoped endpoints take the company in the path, as /api/company/{company_id}/.... Employee-scoped endpoints take only the employee, as /api/employees/{employee_id}/..., and resolve the company themselves. There is no company header and no tenant query parameter.
A company_id your token cannot see is reported as not found rather than as forbidden, so probing ids tells an attacker nothing. Two shapes exist, depending on the endpoint. Create and update answer:
HTTP/1.1 404 Not Found { "detail": "Company not found" }
while the read and list endpoints answer:
HTTP/1.1 400 Bad Request { "detail": "No company found with the given company_id" }
Treat both as the same thing: the id does not exist in the data your token reaches. In particular do not read the 400 as a complaint about your request format.
Rate limits
| Endpoints | Limit |
|---|---|
| Employee create, update, delete, restore, reinstate, and the read endpoints | 25 requests per minute |
| Termination and the pay endpoints | 60 requests per minute for an authenticated partner client |
The batch create and update endpoints take an array, so a migration of two hundred employees is a handful of requests rather than two hundred. Prefer batching over parallel single calls: it is faster, it is kinder to the limit, and you get one result row per employee either way.
Error shapes
Three shapes come back, and which one you get depends on where the request failed. Handle all three.
Schema validation, before your request reaches any business logic. A bad enum value, a malformed date, a negative amount, an unknown field name:
HTTP/1.1 422 Unprocessable Entity { "status_code": 10422, "message": "[{'loc': ('body', 0, 'email_address'), 'msg': 'Invalid email format.', 'type': 'value_error'}]", "data": null }
Business rules, the large majority of errors you will actually hit. The message is in detail, and it is written to be read:
HTTP/1.1 409 Conflict { "detail": "Employee is already terminated. Use the reinstate endpoint to re-hire them." }
Something broke on our side, which you should treat as retryable:
HTTP/1.1 500 Internal Server Error { "error": "An unexpected error occurred. Please try again." }
Read detail first, fall back to message, then error. A client that only reads one field will silently show your users nothing on two thirds of failures.
Retries and safety
Employee writes do not accept an Idempotency-Key header. That header exists on partner checkout, not here. A create you retry blindly after a timeout can produce two employees, and a terminate you retry blindly can fail with a conflict or process a second pay run. Build the safety in yourself.
Two habits make this a non-issue:
- Always send your own
numberon create. It is your employee code, and it must be unique across every company on the account. If a create times out, read the employee back by that number before retrying. Found means it worked; not found means it did not. - Before retrying a termination, read it back.
GET /api/employees/{id}/terminationreturns 404 if the employee has never been terminated, and the termination record if they have. That is a cheap, side-effect-free way to find out whether your first call landed.
Dates, money and lists
- Dates are
YYYY-MM-DD. There are no timestamps in this part of the API, because payroll works in whole days. - Money and hours are decimals. Send them as JSON strings (
"6182.40") rather than floats, so nothing is lost turning your number into binary and back. - List endpoints return a bare JSON array, not an object with a
datakey.
03Creating an employee
One endpoint, an array in and an array out, with per-employee results so a bad row cannot take a good one down with it.
Send an array even for one employee. You get back an array in the same order, one entry per employee you sent, each carrying either the created employee or the reason it failed. The status code tells you which case you are in:
| Status | Meaning |
|---|---|
| 201 | Every employee in the batch was created. |
| 207 | Some were created and some were not. Walk the array and look at errors on each entry. |
| 422 | The batch never ran. One or more payloads failed schema validation, so nothing was created, not even the valid rows. |
That difference matters when you design a migration. Schema errors are all-or-nothing, so validate locally before you send. Business-rule errors, such as a duplicate payroll number, are per-employee, so a two hundred row import can come back 207 with one hundred and ninety eight created and two to fix.
A realistic payload
The minimum payload is in the field reference. This is what a real one tends to look like: identity and address, the tax declaration, how they are paid, their super fund, and the portal permissions their employer wants them to have.
[
{
"first_name": "Marguerite",
"middle_name": "Rose",
"last_name": "Abernethy",
"honorific": "Ms",
"gender": "FEMALE",
"date_of_birth": "1986-03-11",
"address1": "18 Kembla Street",
"city": "Coorparoo",
"state": "QLD",
"country": "AU",
"postcode": "4151",
"email_address": "m.abernethy@example.com",
"phone_mobile": "0412 355 118",
// Employment. start_date and employment_status are both required.
"start_date": "2019-02-04",
"employment_status": "Full-time",
"position": "Operations Manager",
"department": "Operations",
"number": "EMP-1004",
// How they are paid.
"pay_period": "FORTNIGHTLY",
"pay_method": "DIRECT BANK ENTRY",
"pay_rate_per_hour": "58.40",
"standard_hours_per_day": "7.6",
"standard_days_per_week": 5,
// Australian tax declaration. All four booleans are required.
"tax_file_number": "458201134",
"is_foreign_resident": false,
"is_closely_held": false,
"has_claimed_tax_free_threshold": true,
"has_stsl_liability": false,
// Default super fund, by USI and member number.
"default_employee_fund_usi": "STA0100AU",
"default_employee_fund_member_number": "AS-4471902",
// Employee portal access.
"employee_portal_active": true,
"allow_view_payslips": true,
"allow_edit_timesheet": true,
"allow_edit_leave_requests": true
}
]
The response echoes the created employee, including the id you will use for everything that follows:
HTTP/1.1 201 Created [ { "employee": { "id": 8842, "company_id": 412, "number": "EMP-1004", "first_name": "Marguerite", "last_name": "Abernethy", "is_terminated": false, "is_deleted": false, // ... the full employee read model }, "errors": {} } ]
number is unique across every company on the customer's account, not just the one you are creating into. If your own identifiers are only unique per site or per client, prefix them before you send. A collision comes back as a per-employee error inside a 207, which is easy to miss if you only check the status code.
The road to a payable employee
Creating the record is step one of a few. None of the rest are mandatory to hold a record, but a payroll clerk cannot pay someone without them.
Create the employee
Identity, address, the tax declaration for their country, and how often they are paid. Keep the id from the response.
Add a bank account
Required if they are paid by bank transfer, which is nearly everyone. Section 05.
Set the super fund or KiwiSaver settings
In Australia, a default fund USI and member number. In New Zealand, the KiwiSaver status and rates. Section 06.
Attach allowances, if the role has any
Tool, laundry, travel and the rest, either employee-specific or attached from the company's own list. Section 07.
Turn on portal access, if your customer uses it
Set employee_portal_active and the permission flags. The welcome invitation itself is sent by the payroll app, not by this API.
Pay them
Pays are their own endpoint family, and hours normally arrive from a timesheet. Leave balances build up from processed pays, which is why you cannot set an opening balance directly.
04Field reference
One field set covers create and update. This is the whole of it, grouped the way a payroll officer thinks about it rather than the way it is stored.
Both PUT/api/company/{company_id}/employees/create and PATCH/api/company/{company_id}/employees/update validate against the same field set, so everything below applies to both. Creating an employee makes ten of these fields mandatory: first_name, last_name, date_of_birth, address1, city, state, country, postcode, start_date and employment_status. Every other field is optional on create. Update is pure PATCH semantics keyed on the employee's internal id (the only field it requires): send only the fields you want to change, and anything you omit is left exactly as it was. There is no way to blank a field by leaving it out. Both endpoints reject a key they do not recognise outright, an unknown or misspelled field name comes back as a 422 rather than being quietly dropped, because the payload model is built with extra="forbid". A dropped field fails silently; a rejected one fails loudly, and loud is what you want when the field controls tax or bank details.
One more thing worth knowing before you rely on any field below: a field you genuinely omit from a create payload does not necessarily arrive blank. Lightning Payroll applies its own configured default for that setting instead, the same one a new employee gets when added from the desktop app. The notes below call out the specific defaults that matter.
The country field on the employee payload itself decides whether the Australian or the New Zealand required-field rules apply on create. This is not the company's own jurisdiction. Send "country": "NZ" and the API runs the New Zealand branch, asking for nz_tax_code and, usually, ird_number, no matter which jurisdiction the company itself operates under. Send "country": "AU" and it runs the Australian branch instead, asking for tax_file_number and the four Australian tax-declaration booleans. Get this backwards (an NZ-resident employee at an NZ company sent with "country": "AU", say) and one of two things happens: either the create fails a 400 for missing Australian fields you never meant to supply, or it succeeds and leaves you with an employee whose tax setup does not match the company they sit in. Nothing here cross-checks country against the company automatically. There is also no jurisdiction key to send. The payload has no such field, and unknown keys are refused, so adding one rejects the entire batch with a 422 rather than resolving the ambiguity for you. country is the only lever. In short: always send the employee's real country of tax residence in country, and treat it as the field that steers validation, not as address metadata.
Identity and personal details
| Field | Type | Required on create | Notes |
|---|---|---|---|
first_name | string, 1 to 60 characters | required | |
middle_name | string, up to 60 characters | optional | |
last_name | string, 1 to 60 characters | required | |
date_of_birth | date, YYYY-MM-DD | required | Rejected if it falls in the future. |
gender | string, one of MALE, FEMALE, INDETERMINATE, UNKNOWN | optional | Defaults to UNKNOWN if omitted. |
honorific | string, up to 18 characters | optional | E.g. Mr, Ms, Dr. |
number | string, up to 60 characters | optional | Your own employee code, not the internal database id. Unique across every company you hold under this account, not only the one you are creating into: reusing a number that belongs to an employee at another of your companies is rejected. |
Address
| Field | Type | Required on create | Notes |
|---|---|---|---|
address1 | string, 1 to 100 characters | required | |
address2 | string, up to 100 characters | optional | |
city | string, 1 to 60 characters | required | |
state | string, 2 to 3 characters, upper-cased | required | Free-form: no Australian state enum and no New Zealand region enum is enforced at this layer, so a 2 or 3 character code from either country is accepted in the same field. |
country | string, up to 2 characters, upper-cased | required | Drives which required-field branch runs on create. See the callout above. |
postcode | string, exactly 4 digits | required |
Contact and next of kin
The employee's own contact details, plus an optional emergency contact.
| Field | Type | Required on create | Notes |
|---|---|---|---|
email_address | valid email address | optional | |
phone_home | string, empty or 8 to 15 characters (digits, spaces, +, (, ), -) | optional | |
phone_mobile | same shape as phone_home | optional | |
phone_medical | same shape as phone_home | optional | Contact number for medical purposes. |
kin_name | string, up to 60 characters | optional | |
kin_relationship | string, up to 60 characters | optional | |
kin_address1 | string, up to 100 characters | optional | |
kin_address2 | string, up to 100 characters | optional | |
kin_city | string, up to 60 characters | optional | |
kin_state | string, 2 to 3 characters, upper-cased | optional | |
kin_postcode | string, exactly 4 digits | optional | |
kin_phone_home | same shape as phone_home | optional | |
kin_phone_work | same shape as phone_home | optional | |
kin_phone_mobile | same shape as phone_home | optional | |
kin_notes | free text, no length limit enforced | optional |
Employment
| Field | Type | Required on create | Notes |
|---|---|---|---|
start_date | date, YYYY-MM-DD | required | |
employment_status | string, one of Full-time, Part-time, Casual, Labour Hire (matched case and hyphen insensitively) | required | Sets the employee's Single Touch Payroll employment status and the default leave and long-service-leave setup. Distinct from employment_type, which is a separate lookup shown on the payslip. |
employment_type | integer id, or the option's name (string), from GET/api/employment-types | optional | Built-in options are Full-time, Part-time and Casual; you may have added your own. If omitted on create it is derived from employment_status, except for Labour Hire, which has no matching type and is left unset. Shown on the payslip, and reported in Australian SuperStream member registration where that applies. |
employment_tenure | integer id, or the option's name (string), from GET/api/employment-tenures | optional | Built-in options are Permanent and Temporary. Not derived from employment_status: a new employee is Permanent until you set this, so send it explicitly for a fixed-term or temporary hire. |
active_pay_recipient | boolean | optional | Defaults to true if omitted. If false, no pending pays are generated for the employee, but they still count toward your licence limit and still appear in reports. Useful for someone on extended leave who is not being terminated. |
position | string, up to 60 characters | optional | Job title, shown on the payslip. |
department | string, up to 60 characters | optional |
Australian tax declaration
The first five rows below are, in practice, required for an ordinary Australian employee, but the requirement is enforced by a service-side check rather than by the payload schema. Leave one out and you get a 400 for that item once the rest of the payload has already passed validation, not a 422 for the whole request. If you do supply a value that is present but malformed (a tax file number that fails the ATO checksum, for instance), that still fails immediately as a 422.
| Field | Type | Required on create | Notes |
|---|---|---|---|
is_super_only_contractor | boolean | optional | AU Contractor paid super only: no TFN, and excluded from Single Touch Payroll, PAYG and payment summaries. Setting this true drops the tax_file_number requirement below, but not the other four. |
tax_file_number | string | conditional | AU Required on an Australian create unless is_super_only_contractor is true. Checked against the ATO's TFN checksum, so a badly formed number is rejected with 422 the moment you send it. |
tfnd_signed_date | date, YYYY-MM-DD | optional | AU Date the employee signed their TFN declaration. |
is_foreign_resident | boolean | conditional | AU Required on every Australian create, including a super-only contractor. |
has_claimed_tax_free_threshold | boolean | conditional | AU Required on every Australian create, including a super-only contractor. |
has_stsl_liability | boolean | conditional | AU Required on every Australian create, including a super-only contractor. Covers HELP, VSL, SFSS, SSL, ABSTUDY SSL and TSL. This is the field name to send; the old HELP-only column is read-only (see "Fields you cannot write" below). |
is_closely_held | boolean | conditional | AU Required on every Australian create, including a super-only contractor. Closely held employees are typically family members of the business owner or company directors. |
working_holiday_tax_scale_applies | boolean | optional | AU If true, the Working Holiday Maker tax scale is used instead of the standard one. |
include_email_and_phone_in_stp | boolean | optional | AU Defaults to false. Whether the employee's email and phone number are included in Single Touch Payroll reports sent to the ATO. |
abn | string | optional | AU The employee's own Australian Business Number, for a contractor who bills the company. |
New Zealand tax declaration
| Field | Type | Required on create | Notes |
|---|---|---|---|
nz_tax_code | string, one of M, ME, M SL, ME SL, SB, S, SH, ST, SA, SB SL, S SL, SH SL, ST SL, SA SL, CAE, CAE SL, EDW, EDW SL, NSW, NSW SL, STC, STC SL, SLCIR, SLCIR SL, ND, WT | conditional | NZ Required on a New Zealand create. If you send no_declaration: true without it, the API sets it to ND for you rather than rejecting the request. |
ird_number | string | conditional | NZ Required on a New Zealand create unless nz_tax_code is WT or ND, or no_declaration is true. Checked against Inland Revenue's check-digit algorithm. |
has_student_loan | boolean | optional | NZ Forced to false if no_declaration is true. |
no_declaration | boolean | optional | NZ Forces nz_tax_code to ND, has_student_loan to false, and clears ird_number, whatever you sent for any of them. |
Pay and rates
| Field | Type | Required on create | Notes |
|---|---|---|---|
pay_method | string, one of CASH, DIRECT BANK ENTRY, OTHER | optional | Defaults to DIRECT BANK ENTRY if omitted. |
pay_period | string, one of WEEKLY, FORTNIGHTLY, MONTHLY | optional | Defaults to WEEKLY if omitted. |
pay_rate_per_hour | decimal | optional | Used to calculate gross pay from hours worked. |
standard_hours_per_day | decimal | optional | E.g. 7.6. Feeds leave accrual and RDO calculations. |
standard_days_per_week | integer | optional | E.g. 5. |
stp_employment_status | string, one of C, P, F | optional | Defaults to F (full-time) if omitted. Strict on write, rejecting anything outside these three codes; the value you read back on an existing employee is passed through unchanged even if it holds a legacy or otherwise unexpected value. |
Superannuation (AU)
| Field | Type | Required on create | Notes |
|---|---|---|---|
default_employee_fund_member_number | string, up to 20 characters | optional | AU |
default_employee_fund_usi | string, up to 20 characters | optional | AU Checked against Lightning Payroll's superannuation fund registry after the request is accepted. Only a definitive "invalid USI" response rejects it with 400; if the registry lookup itself is unavailable, the change is allowed through unchecked rather than blocking the request. |
super_rate | decimal | optional | AU Total super rate applied to the employee's upcoming pays, e.g. 0.115 for 11.5%. Anything above compulsory_super_rate is reported as Reportable Employer Super Contributions. |
compulsory_super_rate | decimal | optional | AU The legally required minimum rate, e.g. 0.105 for 10.5%. |
is_super_enabled | boolean | optional | AU If false, no super is calculated for this employee regardless of the two rates above. |
is_super_age_threshold_enabled | boolean | optional | AU Whether the employee's age is factored into super eligibility. |
super_based_on | string, OTE or GROSS | optional | AU Whether super is calculated on Ordinary Time Earnings (recommended) or Gross Pay. |
KiwiSaver and ESCT (NZ)
| Field | Type | Required on create | Notes |
|---|---|---|---|
kiwisaver_employee_rate | decimal | optional | NZ Fraction, e.g. 0.03 for 3%. Falls back to Lightning Payroll's current default KiwiSaver rate if omitted. |
kiwisaver_employer_rate | decimal | optional | NZ Same, employer-paid side. |
kiwisaver_status_code | string | optional | NZ Defaults to AE (auto-enrol) if omitted. |
kiwisaver_existing_action | string | optional | NZ Only meaningful when the employee is an existing KiwiSaver member. |
kiwisaver_cec_obligation | string | optional | NZ E.g. NONE. |
esct_rate | decimal | optional | NZ Employer Superannuation Contribution Tax rate, e.g. 0.105 for 10.5%. Defaults to 0.105 if omitted. |
employer_contrib_tax_method | string | optional | NZ |
employer_contrib_paye_fraction | decimal | optional | NZ |
Leave accrual settings
| Field | Type | Required on create | Notes |
|---|---|---|---|
is_leave_enabled | boolean | optional | Defaults to true. If false, no leave accrues for this employee at all. |
include_leave_loading_in_super | boolean | optional | Defaults to false. Whether leave loading counts toward superannuation. |
accrue_leave_on_hours_worked | boolean | optional | If true, leave accrues pro rata on hours actually worked. If false, it accrues by the pay period instead. |
accrue_leave_on_overtime_hours | boolean | optional | Whether overtime hours also contribute to leave accrual. |
accrue_holiday_leave_per_hour | decimal | optional | Per-hour accrual rate for annual/holiday leave. Defaults to 0.076923 (20 days a year) if omitted. Used when accrue_leave_on_hours_worked and is_leave_enabled are both on. |
accrue_sick_leave_per_hour | decimal | optional | Per-hour accrual rate for sick/personal leave. Defaults to 0.038462 (10 days a year) if omitted. |
accrue_lsl_per_hour | decimal | optional | Per-hour accrual rate for long service leave. Defaults to 0.016667 (8.6667 weeks per 10 years) if omitted; some states use a different statutory entitlement (0.025 for South Australia and the Northern Territory's 13 weeks). |
num_sick_leave_days_per_year | integer | optional | E.g. 10. |
num_holiday_leave_days_per_year | integer | optional | E.g. 20. |
is_lsl_enabled | boolean | optional | |
lsl_x_years | integer | optional | Years of service required before the employee qualifies for long service leave. |
lsl_accrued_x_years | integer | optional | Weeks of long service leave accrued for every lsl_x_years years of service. |
hourly_amount_for_workers_comp_leave | decimal | optional | Defaults to 0.00 if omitted. |
hourly_amount_for_paid_parental_leave | decimal | optional | Defaults to 0.00 if omitted. |
rdo_hours | decimal | optional | Current accrued Rostered Days Off balance, in hours. Unlike the leave-hour balances covered below, this one is writable directly. |
toil_hours | decimal | optional | Current accrued Time Off In Lieu balance, in hours. Also writable directly. |
leave_loading_percentage | decimal | optional | E.g. 0.175 for 17.5%. Defaults to 0 if omitted. |
Bank details
These are the legacy inline fields on the employee record itself, a primary account that always exists and one optional secondary split. They are entirely separate from the newer, ranked multi-account sub-resource at GET/api/employees/{employee_id}/bank-accounts, which supports up to 10 accounts and is documented in its own section. Both mechanisms are live at the same time on this API, and nothing here cross-validates a caller who mixes the two.
| Field | Type | Required on create | Notes |
|---|---|---|---|
primary_bank_bsb | string, empty or 6 digits with an optional hyphen after the third digit (123456 or 123-456) | optional | |
primary_bank_account_number | string, up to 12 characters | optional | |
primary_bank_account_name | string, up to 32 characters | optional | |
secondary_bank_bsb | same shape as primary_bank_bsb | optional | |
secondary_bank_account_number | same shape as primary_bank_account_number | optional | |
secondary_bank_account_name | same shape as primary_bank_account_name | optional | |
secondary_bank_reference | string, up to 18 characters | optional | Lodgement reference put on the secondary account's deposit. |
secondary_bank_amount_per_period | decimal | optional | Fixed amount paid into the secondary account each pay period. The remainder of the pay goes to the primary account. |
Employee portal access and permissions
Whether an employee can use the online portal at all, and what they can see and edit once they are in, is controlled entirely by boolean flags: employee_portal_active, allow_edit_timesheet, allow_edit_timeclock, allow_edit_account_details, allow_edit_tax_settings, allow_edit_bank_accounts, allow_edit_super_details, allow_edit_leave_requests, allow_edit_leave_requests_when_negative, allow_view_holiday_leave_balance, allow_view_sick_leave_balance, allow_view_long_service_leave_balance and allow_view_payslips. All 13 are optional booleans, off unless you set them true.
| Field | Type | Required on create | Notes |
|---|---|---|---|
employee_portal_active | boolean | optional | The master switch for portal access. Setting this true does not, by itself, send the employee a welcome or invite email: that action is not reachable through this API. |
allow_edit_leave_requests_when_negative | boolean | optional | Only meaningful once allow_edit_leave_requests is also true; it relaxes that permission to also allow a request that would take, or already has taken, the employee's balance negative. |
Payslip display options
What appears on the employee's payslip is likewise mostly boolean flags: show_roster_summary_on_payslip, show_position_on_payslip, show_department_on_payslip, payslip_show_holiday_leave_balance, payslip_show_sick_leave_balance, payslip_show_lsl_balance, payslip_show_negative_leave_balances, payslip_show_custom_balances, payslip_show_hours_and_rate, payslip_show_allowance_units, payslip_show_base_ordinary_rate, payslip_show_super_ytd, payslip_show_ytd, payslip_show_zero_dollar_leave and payslip_time_non_decimal. All 15 are optional booleans, but they are not all off by default, and this is the group most likely to surprise you. Omit them on create and eight arrive switched on: show_position_on_payslip, payslip_show_holiday_leave_balance, payslip_show_negative_leave_balances, payslip_show_custom_balances, payslip_show_hours_and_rate, payslip_show_allowance_units, payslip_show_ytd and payslip_show_zero_dollar_leave. The rest default off. If your product promises a particular payslip layout, send every flag you care about explicitly rather than relying on an omitted field being false. Two remaining fields in this group are not booleans and are worth their own row:
| Field | Type | Required on create | Notes |
|---|---|---|---|
payslip_leave_units | string, HOURS or DAYS | optional | The unit leave balances are shown in on the payslip. |
payslip_note | string | optional | Free-text note printed on the employee's upcoming payslips. |
Fields you cannot write
The Employee object you get back from a read carries a number of fields that are not part of the create or update payload at all. Sending any of them back, a common trap when you round-trip a fetched record straight into an update, is rejected with a 422 rather than silently ignored, under the same extra="forbid" rule covered above. If you build updates by fetching an employee, editing a few fields and PATCHing the whole object back, strip these first.
| Field | Notes |
|---|---|
holiday_leave_hours read-only | Current annual/holiday leave balance, in hours. |
sick_leave_hours read-only | Current personal/sick leave balance, in hours. |
lsl_leave_hours read-only | Current long service leave balance, in hours. |
rdo_days read-only | Current RDO balance in days (derived from rdo_hours divided by standard_hours_per_day). rdo_hours itself, in the leave accrual group above, is writable. |
toil_days read-only | Current time-off-in-lieu balance in days. toil_hours is writable. |
pay_period_gross, annual_gross, current_ytd_gross read-only | Pay figures derived from the employee's current settings and pay history. |
period_student_loan_cir, period_student_loan_bor read-only NZ | Student loan deduction amounts from the employee's most recent pay. |
income_stream_country_code, opted_out, opted_out_signature_date, late_opt_out_reason, other_late_opt_out_reason read-only NZ | KiwiSaver opt-out history. |
has_help_liability read-only AU | Deprecated, HELP-only legacy column. Send has_stsl_liability instead, which also covers VSL, SFSS, SSL, ABSTUDY SSL and TSL. |
is_australian_resident read-only | Derived from is_foreign_resident, which is what you should set instead. |
income_stream, stp_id, tax_treatment_code, single_touch_residency_status, readable_stp_employment_status read-only AU | Single Touch Payroll reporting metadata, computed from the employee's other settings. |
default_employee_fund_name read-only AU | The name of the fund identified by default_employee_fund_usi, which is writable. |
username read-only | The employee's online portal login username. |
standard_hours_per_week read-only | Calculated as standard_hours_per_day times standard_days_per_week, both of which are writable. |
The leave-balance fields deserve special attention, since they are the ones integrators most often expect to be able to set directly and cannot. holiday_leave_hours, sick_leave_hours and lsl_leave_hours only ever change as a side effect of processing a pay: run a pay that accrues leave, or pays leave out, and the balance moves accordingly. There is no endpoint on this API for setting an opening balance directly. If you are migrating an employee from another system and need to seed their starting balance, the way to do it is to process a pay that lands them at the figure you want. The one exception is delete_non_rdo_and_toil_leave_items on update, which wipes every non-RDO, non-TOIL leave balance in a single call rather than setting any of them to a chosen value, useful for clearing a balance that was misconfigured rather than for seeding a real one.
Minimum viable payloads
The smallest payload that creates an Australian employee, sent as a single-item array to PUT/api/company/{company_id}/employees/create:
[
{
"first_name": "Alice",
"last_name": "Smith",
"date_of_birth": "1990-01-01",
"address1": "123 Example Street",
"city": "Sydney",
"state": "NSW",
"country": "AU",
"postcode": "2000",
"start_date": "2024-07-01",
"employment_status": "Full-time",
"tax_file_number": "111111111",
"is_foreign_resident": false,
"is_closely_held": false,
"has_claimed_tax_free_threshold": true,
"has_stsl_liability": false
}
]
"111111111" is one of the ATO's recognised placeholder TFNs (new payee, no declaration yet), so it passes the checksum without belonging to a real person, handy for test data. For a super-only contractor, drop tax_file_number and add "is_super_only_contractor": true; the other four Australian booleans are still required.
The smallest payload for a New Zealand employee, on the same endpoint:
[
{
"first_name": "Alice",
"last_name": "Smith",
"date_of_birth": "1990-01-01",
"address1": "123 Example Street",
"city": "Auckland",
"state": "AUK",
"country": "NZ",
"postcode": "1010",
"start_date": "2024-07-01",
"employment_status": "Full-time",
"nz_tax_code": "M",
"ird_number": "49091850"
}
]
Drop ird_number entirely when nz_tax_code is "WT" or "ND", or when you send "no_declaration": true (which sets nz_tax_code to "ND" for you), for an 11-field minimum with no IRD number at all.
is_super_only_contractor waives the tax file number, and nothing else. The four tax-declaration booleans are still required, and a create without them is refused by name:
Missing required AU employee fields: is_foreign_resident, is_closely_held, has_claimed_tax_free_threshold, has_stsl_liability
Send them as false for a contractor who makes no declaration. That error message is the most useful one on this endpoint, because it names every field it wants: if a create is rejected for missing fields, read the list rather than guessing.
Where these fields end up
Every field above surfaces somewhere your customer can see it. It is worth knowing which screen, because that is where support questions come from: a clerk describes a screen, and you need to know which field they mean. Identity and address land on the Personal Details screen shown in section 03; the rest are below.
05Bank accounts
An employee can hold up to ten accounts, ranked, with fixed amounts split off the top. There are two ways to write them, and you want the newer one.
The employee payload also carries primary_bank_* and secondary_bank_* fields. Those are the older single-and-split model, kept so existing integrations keep working. Use this sub-resource instead: it supports up to ten accounts, it gives each one an id you can update, and it is what the payroll app writes. Do not drive both at once for the same employee. Nothing stops you, and the result will not be what either side expects.
Account identity differs by country
Which identity fields are required depends on the company's jurisdiction, not on the employee's country field. This is the one place in the employee lifecycle where the company decides.
Australia
bsb, six digits, with or without the hyphen:"084-234"and"084234"are both accepted.account_number, up to nine digits, sent as a string so leading zeros survive.
New Zealand
- All four parts of the canonical bank-branch-account-suffix number:
nz_bank_id(2 digits),nz_branch(4 digits),nz_account_base(7 or 8 digits) andnz_suffix(2 to 4 digits). - Send all four together. Re-keying a New Zealand account clears any Australian values it held.
Fields
| Field | Type | Required | Notes |
|---|---|---|---|
account_name | string, up to 32 characters | required | The name on the account. |
bsb | string | conditional | AU Required with account_number for an Australian company. |
account_number | string | conditional | AU String, not a number, so leading zeros are preserved. |
nz_bank_id, nz_branch, nz_account_base, nz_suffix | strings | conditional | NZ All four required together for a New Zealand company. |
rank | integer, 0 or more | optional | Where the account sits in the split. 0 or null means the default account, the one paid whatever is left of the pay. 1 or more is a secondary position, paid a fixed amount. Omitting rank appends the account as the lowest priority secondary, which then requires an amount. Ranks stay contiguous: inserting at a taken position renumbers the rest, and a rank past the end just goes last. |
amount | decimal, 2 places | conditional | Required, and greater than zero, on a secondary account (rank 1 or more). A ranked account with no amount is skipped when deposits are worked out, so it would look saved and never be paid. Not accepted on the default account, which takes the remainder; zero is tolerated there and ignored, so you can read an account back and send it whole to reposition it. |
alt_transaction_reference | string, up to 18 characters | optional | What the employee sees on their bank statement for this line, in place of the default reference. Useful for a savings or child support split. |
Adding a split
A typical two-account setup: the whole net pay goes to an everyday account, except a fixed 450 dollars into savings.
// The everyday account. rank 0 makes it the default, so it takes the // remainder and must not carry an amount. { "account_name": "M R Abernethy", "bsb": "084-234", "account_number": "119043872", "rank": 0 } // Then the split: secondary position 1, a fixed amount, its own reference. { "account_name": "M R Abernethy Savings", "bsb": "064-158", "account_number": "220771904", "rank": 1, "amount": "450.00", "alt_transaction_reference": "Savings" }
Rules worth coding for
- The first account is always the default, whatever
rankyou send, because an employee must always have exactly one account taking the remainder. Send"rank": 1on an employee's very first account and it comes back as the default with a null rank. For the same reason, a rank of 1 or more is rejected on whichever account is currently the default: promote a different account instead, which demotes the old one for you. - Ten accounts maximum. An eleventh is 409.
- No duplicate account numbers on the same employee, also 409.
- Deleting the default promotes the next account by rank, so an employee with more than one account is never left without a default. Deleting an employee's only account is allowed, and leaves them with none, matching what the payroll app permits. If your interface offers a delete button, that is the case to guard.
- The employee may own their own bank details. If your customer has given this employee permission to edit bank accounts in the employee portal, deleting one through the API returns 409. The employee is the source of truth while that permission is on. Check
allow_edit_bank_accountson the employee before you offer bank editing in your own interface. - A partial update re-validates the whole identity. Sending just a new
account_numberre-checks it against the BSB, so a valid pair cannot become an invalid one.
06Super and KiwiSaver
Retirement contributions are the one part of employee setup where the two countries barely resemble each other, and where the API deliberately does less than you might expect.
Australia
Set the employee's default fund on the employee record itself, with two fields:
default_employee_fund_usi, the fund's Unique Superannuation Identifier.default_employee_fund_member_number, their membership number with that fund.
Contribution behaviour is employee fields too: is_super_enabled, super_rate, compulsory_super_rate, super_based_on (OTE or GROSS) and is_super_age_threshold_enabled.
New Zealand
There is no fund to nominate. KiwiSaver is configured with rates and a status code on the employee:
kiwisaver_employee_rateandkiwisaver_employer_rate.kiwisaver_status_code,kiwisaver_existing_actionandkiwisaver_cec_obligation.esct_ratefor employer superannuation contribution tax, plusemployer_contrib_tax_methodandemployer_contrib_paye_fraction.
The super fund sub-resource is read and delete only, and it is Australia only. There is no endpoint to add a fund or to add a second one. Set the default fund through default_employee_fund_usi and default_employee_fund_member_number on create or update. An employee who needs contributions split across more than one fund has to be set up in the payroll app.
The USI is validated against the live fund register, and a bad one fails the create. It is not accepted and flagged later. An expired, mistyped or wound-up USI comes back per-employee as:
USI REI0001AU not found in FVS. It is likely invalid, expired, or not accepting SuperStream messages.
Where that message lands depends on the call. In a create batch where at least one other employee succeeded, it arrives as a per-entry error inside a 207. If nothing in the batch succeeded, which is what happens when you send a single employee, you get the 400 directly as {"detail": ...} with no result array. On an update it aborts the whole request. So handle both shapes, and do not assume a bad USI is always reported per entry.
Two consequences worth designing for: keep your fund list fresh, because funds do merge and close, and do not block the whole employee on it. Create the employee without a fund, then set the fund in a follow-up update once you have a USI that validates.
Deleting a fund is not always a hard delete. A fund that is the employee's default, or that already has contribution history behind it, is retired rather than removed, so past payments still reconcile. A fund with no history is removed outright. Either way the response is 200 and the fund stops being used.
07Allowances
Two kinds: one employee's own allowance, and a company-wide allowance attached to whoever should get it. Both are here.
A GET returns both buckets, so you can tell what belongs to the employee and what they inherited:
{
"employee": [ /* this employee's own allowances */ ],
"company": [ /* company allowances attached to them */ ]
}
Creating an employee allowance needs a description and an amount. For an Australian company it also needs a classified allowance_category, because the category is what gets reported:
| Code | Category |
|---|---|
CD | Cents per kilometre |
AD | Award transport |
LD | Laundry |
MD | Overtime meals |
RD | Domestic and overseas travel or accommodation |
TD | Tools |
KN | Tasks |
QN | Qualifications and certificates |
OD | Other |
{
"description": "Tool allowance",
"amount": "26.50",
"allowance_category": "TD"
}
- Attaching a company allowance is idempotent.
PUTthe same allowance twice and the second call changes nothing, so you can converge on a desired state without tracking what you already did. - Renaming an allowance can reach backwards. A
PATCHtakes an optionalpropagate_description_to_historyflag which rewrites the description on past pays too. Leave it off unless you want payslips already issued to read differently. - A company allowance in use will not delete. It returns 409 while any employee is attached, unless you pass
force=true. - Editing a company allowance replaces its whole attached set. If you send
employee_ids, send every employee who should have it, not just the ones you are adding.
There is no deduction endpoint on this surface. Recurring deductions are set up in the payroll app, and a one-off deduction can be put on a single pay when you create it. A fixed amount going to a second bank account, which is how a lot of "deductions" are actually implemented, is a bank account split.
08Reading employees
Three ways in: the whole company, one employee by our id, one employee by your code.
Listing a company
The list endpoint takes one query parameter, status, and returns a bare array sorted by last name, then first name, then middle name.
status | Returns |
|---|---|
| omitted | Everyone not deleted. Current staff and former staff together. |
active | Current staff only. |
terminated | Former staff only. |
deleted | Soft-deleted records only. |
all | Everything, deleted records included. |
This endpoint is not paginated. There is no limit, no offset and no total count: you get the entire matching set, and every employee arrives as the full read model of well over a hundred fields. On a large company that is a big response. Fetch it on a schedule rather than per page view, cache what you need, and use the webhooks to know when to fetch again.
Fetching one employee
By our id when you have stored it, which is the normal case after a create:
GET /api/employees/8842
Or by the number you assigned, which is useful when your own system is the system of record and you never stored our id:
GET /api/employees/payroll-number/EMP-1004
Both return the same shape. Because number is unique across the whole account, the second form does not need a company.
Deciding what changed
The read model has no version field and no updated timestamp, so there is nothing to compare against except the values themselves. If you need change detection, keep a hash of the fields you care about and compare on read. In practice most integrations only care about a dozen fields, and hashing those is cheaper and far less noisy than diffing the whole record.
09Updating an employee
A batch PATCH keyed on whichever identifier you have. Send only what changes.
This takes an array and returns one result per entry. Each entry needs an identifier and then only the fields you are changing. Anything you leave out is left alone, so there is no read-modify-write cycle and no risk of clobbering a field a payroll clerk changed while you were not looking.
Update is not as forgiving as create. Create isolates each entry, so one bad row cannot sink the batch. Update does not: an identifier that does not resolve raises 404 for the whole request, and entries that had already been applied before the bad one stay applied. You get no result array to inspect in that case.
So keep update batches small, and resolve your identifiers first. The pattern that works is to read the employees you intend to change, update only the ones you found, and treat a 404 as "my mapping is stale" rather than as "this entry failed".
[
{
"id": 8844,
"position": "Senior Payroll Officer",
"pay_rate_per_hour": "45.10"
}
]
By default each entry is matched on id, our internal identifier. If you would rather key on your own code, add identifier_type=employee_number to the query string and put the code in number:
PATCH /api/company/412/employees/update?identifier_type=employee_number
[
{
"id": 8844, // still required by the schema, ignored for matching
"number": "EMP-1006",
"position": "Senior Payroll Officer"
}
]
id is required on every update entry whatever identifier_type says. The body is validated before the query parameter is looked at, so an entry carrying only number is rejected with 422 for a missing id. When you are keying on number, the id you send is not used for matching, so if you genuinely do not hold ours, send any integer and let number do the work.
What you cannot change this way
- Leave balances. Accrual settings are writable, balances are not. A balance is the arithmetic of processed pays and adjustments, and letting an integration set it directly would put an employee's entitlement out of step with the pays that produced it. The one exception is that RDO and time-off-in-lieu hours are writable, because they are tracked rather than accrued from a formula.
- Anything derived. Year-to-date figures, tax treatment codes, the readable form of a status. They are on the read model and rejected on write.
- Pay period, while a termination is pending. If a staged termination pay exists for this employee,
pay_periodis stripped from your payload and reported back as a per-field error, while every other field in the same entry still applies. The response is 207, not a top-level failure, so readerrors.pay_periodon the entry rather than assuming the whole update was refused. Finish or discard the termination first, otherwise the final pay would be calculated on one frequency and paid on another.
An update that changes nothing is still a write, and it still emits an employee.updated webhook if any column really moved. If you sync on a timer, compare before you send: it keeps your own webhook traffic honest and makes the delivery log worth reading.
10Termination
The biggest call in this guide. One request ends the employment, builds the final pay, values the unused leave, taxes it under the right regime and reports the cessation.
Terminating is not a flag you set. The call creates a pay, attaches the termination to it, calculates every payout component, taxes the result and processes the pay, all in the one request. When it returns 200 the employee is terminated and the final pay exists. When it fails, no pay is left behind and the employee is untouched, so a rejected termination is safe to correct and retry.
Two dates, doing different jobs. termination_date is the last day of employment, and it is what leave is valued at. pay_date is the day the final pay is paid, and it decides which pay run the termination lands in and which tax year it is reported against. They are usually days apart, and getting them the wrong way round is the most common cause of a final pay that looks almost right.
Terminating inside a pay run
If you already create pays through the API, you do not need a separate termination call. PUT/api/company/{company_id}/pays/create accepts an optional termination block on each pay, taking the same fields and following the same rules. Use it when the final pay also carries ordinary hours for the last part-week worked, so the wages and the payout are priced together on one pay.
One consequence worth knowing: a percentage-based deduction on that pay is calculated against the whole final pay, payout included, not just the wages. That is correct, and it is usually a larger number than integrators expect.
A pay carrying a termination cannot be rebuilt through PUT/api/company/{company_id}/pays/{pay_id}. That returns 409. To change a termination, delete the pay, which removes the termination with it, then terminate again with the corrected figures.
Termination reference
The request body for POST/api/employees/{employee_id}/terminate is country-exclusive, not just country-aware. Send an Australian field for a New Zealand employee, or a New Zealand field for an Australian employee, and the whole request is rejected with 400 naming every offending field. It is not ignored, and it is not silently dropped.
For example, sending reason and is_unused_sick_paid for a New Zealand employee is rejected: those are Australian fields. Sending payment_in_lieu_amount for an Australian employee is rejected the same way, because Australia calculates that amount from payment_in_lieu_type and the notice fields rather than accepting it directly.
Shared request fields
These fields apply to both countries, though a few are priced or gated differently depending on which one the employee belongs to.
| Field | Type | Required | Notes |
|---|---|---|---|
termination_date | date, YYYY-MM-DD | required | The last day of employment. Must be on or after the employee's start_date, or the request is rejected with 400. In New Zealand this is also the employment finish date every leave balance is valued as at. |
pay_date | date, YYYY-MM-DD | required | Selects the pay run the final pay is created on, and the date the termination is reported against for Single Touch Payroll or payday filing. Usually the same as termination_date. Only present on POST /terminate; when a termination is embedded in a pay creation call instead, the pay's own date is used. |
is_manual_leave_amounts | boolean | optional, default false | Set true to supply the unused-leave payout amounts yourself instead of having Lightning Payroll work them out from the employee's balances. See Manual amounts below for exactly what this unlocks. |
is_payment_in_lieu_paid | boolean | optional | Whether payment in lieu of notice is being paid at all. If any notice field is sent while this is not true, the request is rejected with 422. In Australia it gates payment_in_lieu_type/notice_hours/notice_lump_sum; in New Zealand it gates payment_in_lieu_amount. |
redundancy_amount | decimal, ≥ 0 | optional | AU A genuine redundancy or early retirement scheme payment, part of the employment termination payment. NZ A redundancy payment, taxed as extra pay and not liable for the ACC earners' levy or KiwiSaver. |
non_etp_amount | decimal, ≥ 0 | optional (conditional in NZ) | AU A free-form payout that is not an employment termination payment. NZ The "additional termination amount". Only accepted with is_manual_leave_amounts set (400 otherwise); see the New Zealand table below. |
unused_holiday_amount | decimal | optional, manual only | Unused annual/holiday leave payout. Australia rejects a negative value with 400. New Zealand accepts a negative value, meaning leave taken in advance of accruing it. |
Australian request fields
| Field | Type | Required | Notes |
|---|---|---|---|
reason | enum | optional, default TERMINATION | Why employment ended, for tax purposes. Drives the ETP tax treatment and the Lump Sum A type, and sets the STP cessation type for every reason except TERMINATION. See Enumerations. |
cessation_type_code | enum | conditional | Only accepted when reason is TERMINATION; sending it with any other reason is rejected with 422, because that reason sets the code itself. Defaults to V (voluntary cessation) when omitted. |
payment_in_lieu_type | enum | conditional | Required (400) when is_payment_in_lieu_paid is true. NOTICE_HOURS pays notice_hours at the employee's hourly rate; NOTICE_LUMP_SUM pays notice_lump_sum as entered. The resulting amount comes back as payment_in_lieu_amount; it cannot be sent directly here (400). |
notice_hours | decimal, ≥ 0 | conditional | Required, and must be more than zero, when payment_in_lieu_type is NOTICE_HOURS. Mutually exclusive with notice_lump_sum, sending both is rejected with 422. |
notice_lump_sum | decimal, ≥ 0 | conditional | Required, and must be more than zero, when payment_in_lieu_type is NOTICE_LUMP_SUM. Mutually exclusive with notice_hours. |
is_unused_holiday_paid | boolean | optional | Pay out unused annual/holiday leave. Omit it and Lightning Payroll decides exactly as the termination wizard does: on for an employee who is not a casual and has a leave balance remaining, off otherwise. An explicit value always overrides the derived default. |
is_unused_leave_loading_paid | boolean | optional | Pay out unused leave loading. Omit it and it defaults on when holiday leave is being paid out and the employee has a leave loading percentage, off otherwise. |
is_unused_lsl_paid | boolean | optional | Pay out unused long service leave. Omit it and Lightning Payroll decides from the employee's length of service and the long-service-leave rules of the company's state. |
is_unused_sick_paid | boolean | optional, default false | Pay out unused sick/personal leave. Off unless explicitly turned on: unused sick leave is not ordinarily payable on termination in Australia. New Zealand never pays it out at all, at any setting. |
redundancy_tax_free_pilon_component | decimal, ≥ 0 | conditional | Only accepted when reason is REDUNDANCY (422 otherwise). The part of the payment in lieu of notice treated as tax-free redundancy rather than an extra amount, so it may not exceed the payment in lieu of notice actually being paid (400 if it does). Checked after the payout is calculated, since the payment-in-lieu amount itself is derived from the notice fields. |
etp_amount | decimal, ≥ 0 | optional | Another payout amount that is an employment termination payment, such as an ex-gratia payment or golden handshake. |
unused_leave_loading_amount | decimal, ≥ 0 | optional, manual only | Unused leave loading payout, entered directly instead of calculated. |
unused_sick_amount | decimal, ≥ 0 | optional, manual only | Unused sick/personal leave payout, entered directly. |
unused_lsl_pre_august_1978 | decimal, ≥ 0 | optional, manual only | Unused long service leave accrued before August 1978, which is taxed at its own rate. |
unused_lsl_august_1978_to_august_1993 | decimal, ≥ 0 | optional, manual only | Unused long service leave accrued between August 1978 and August 1993. |
unused_lsl_post_august_1993 | decimal, ≥ 0 | optional, manual only | Unused long service leave accrued after August 1993. This is where Lightning Payroll puts the whole balance unless you split it across the three eras. |
normal_earnings | decimal, ≥ 0 | optional, manual only | The employee's normal earnings for one pay period, used to work out the whole-of-income cap on an ETP. Lightning Payroll otherwise takes this from the employee record. |
tax_summaries | array of objects | optional, manual only | Hand-entered ETP tax summary rows, replacing the whole set Lightning Payroll would otherwise calculate. Sending this replaces every existing row, so include every one you want kept. See the row shape below and etp_code in Enumerations. |
Each entry in tax_summaries is its own object:
| Field | Type | Required | Notes |
|---|---|---|---|
etp_code | enum | required | See Enumerations. |
tax_withheld | decimal, ≥ 0 | optional | Tax withheld from this ETP row. |
taxable_component | decimal, ≥ 0 | optional | Taxable component of this ETP row. |
tax_free_component | decimal, ≥ 0 | optional | Tax-free component of this ETP row. |
lump_sum_d | decimal, ≥ 0 | optional | Lump Sum D: the tax-free part of a genuine redundancy carried by this row. |
New Zealand request fields
non_etp_amount (in the shared table above) needs a New Zealand-specific warning: outside manual mode, Lightning Payroll uses this exact field to hold the statutory 8% holiday pay owing since the employee's last anniversary. Send an amount of your own into it without is_manual_leave_amounts set and the request is rejected with 400 rather than letting your figure be silently overwritten by the statutory one.
| Field | Type | Required | Notes |
|---|---|---|---|
payment_in_lieu_amount | decimal, ≥ 0 | conditional | Payment in lieu of notice, entered directly. Required, and must be more than zero, when is_payment_in_lieu_paid is true (400 otherwise). Rejected with 400 on an Australian company, where the equivalent amount is calculated instead of entered. |
unused_alt_holiday_amount | decimal, ≥ 0 | optional, manual only | Unused alternative holidays payout (Holidays Act 2003 s.61), entered directly instead of calculated. Internally this writes both the alternative-holiday balance and the legacy leave-loading column, so the payslip and the tax calculation agree; you only need to send the one field. Calculated by default in days at relevant daily pay. |
nz_ytd_taxable | decimal, ≥ 0 | optional | Taxable earnings for this employee, year to date at the finish date, used to apply the annual ACC earners' levy cap to the extra pay. Omit it if Lightning Payroll already holds the whole tax year; supply it if earnings were part-year in another system. A value of 0 is read as "not supplied". |
Manual amounts
is_manual_leave_amounts is the API equivalent of "Show Advanced Settings" in the Australian termination wizard and "Enter leave amounts manually" on the New Zealand employment finish screen. Leave it false (the default) and Lightning Payroll works out every unused-leave payout from the employee's balances, which is almost always what you want. Set it true and it unlocks exactly these fields:
unused_holiday_amountunused_leave_loading_amount(Australia)unused_alt_holiday_amount(New Zealand)unused_sick_amount(Australia)unused_lsl_pre_august_1978,unused_lsl_august_1978_to_august_1993,unused_lsl_post_august_1993(Australia)normal_earnings(Australia)tax_summaries(Australia)
Send any of them while is_manual_leave_amounts is false or omitted and the whole request is rejected with 422, because Lightning Payroll would otherwise recalculate every one of these from the employee's leave balances and overwrite whatever value you sent before it ever reached the payslip.
Enumerations
reason (termination reason):
| Value | Meaning |
|---|---|
TERMINATION | An ordinary termination. |
INVALIDITY | The employee can no longer work through ill health. |
DEATH_DEPENDENT | Death of the employee, benefits paid to a dependant. |
DEATH_NON_DEPENDENT | Death of the employee, benefits paid to a non-dependant. |
DEATH_ESTATE | Death of the employee, benefits paid to the estate. |
REDUNDANCY | A genuine redundancy or early retirement scheme. |
EARLY_RETIREMENT is not accepted on the wire. Send an early retirement scheme payment as REDUNDANCY, which carries the same tax treatment; sending EARLY_RETIREMENT is rejected with a 422.
cessation_type_code (STP cessation type):
| Value | Meaning |
|---|---|
V | Voluntary cessation: resignation or retirement initiated by the employee. |
F | Dismissal: employer-initiated termination. |
C | Contract cessation: natural conclusion of a limited-term engagement. |
T | Transfer to another business, employer or payroll system. |
I (ill health), D (deceased) and R (redundancy) exist in the underlying column but are not accepted on the wire: they are derived automatically from reason (INVALIDITY sets I, any DEATH_* sets D, REDUNDANCY sets R) and cannot be chosen directly.
payment_in_lieu_type:
| Value | Meaning |
|---|---|
NOTICE_HOURS | Pay out notice_hours at the employee's hourly rate. |
NOTICE_LUMP_SUM | Pay out notice_lump_sum as entered. |
END_OF_NOTICE_DATE is not accepted on the wire. Send the notice you are actually paying, either as hours or as a lump sum.
etp_code (on each tax_summaries row):
| Value | Meaning |
|---|---|
R | Redundancy, invalidity or early retirement. |
O | Other: an ex-gratia payment or golden handshake. |
S | A split of a type R payment. |
P | A split of a type O payment. |
D | Death benefit paid to a dependant. |
N | Death benefit paid to a non-dependant. |
B | A split of a type N payment. |
T | Death benefit paid to the trustee of the estate. |
What comes back
The response is pruned to match the employee's country before it is sent: an Australian company never sees a New Zealand field, and a New Zealand company never sees an Australian one, whether the termination is read back on its own or nested inside a pay.
Australia gets back its lump sums and ETP components: total_amount, total_etp_amount, total_non_etp_amount, unused_lsl_amount (the three long-service-leave eras summed), unused_lsl_tax_amount, unused_holiday_and_loading_tax_amount, lump_sum_a, lump_sum_b, lump_sum_d, etp_tax_withheld, etp_taxable_component, etp_tax_free_component, and the tax_summaries array itself, whether you supplied it or Lightning Payroll calculated it.
New Zealand gets back tax_amount and unused_holiday_and_loading_tax_amount as before, plus nz_extra_pay_tax_breakdown: a structured object with one entry per termination component, unused_holiday, alt_holiday, redundancy and other, each carrying its own amount, tax, a components map of the calculator's named tax and levy lines, and applied_personal_rate. The breakdown also carries total_tax and taxed_at_low_rate, the flag that sets the lump sum indicator on the Employment Information (payday) return.
Reading a termination back through the employee (GET/api/employees/{employee_id}/termination, or the response of POST/api/employees/{employee_id}/terminate itself) adds six fields on top of the plain termination: pay_id, pay_date, is_pay_processed, employee_id, employee_number and is_employee_terminated. The last two matter together: a New Zealand termination can come back with is_pay_processed: false and is_employee_terminated: false while its pay is still staged, because New Zealand only flags the employee terminated once that pay is processed.
How the two countries are calculated
Australia
Unused leave is valued in hours against the employee's balance and pay rate: annual/holiday leave, leave loading on top of it if the employee has a loading percentage, long service leave subject to the qualifying rules of the company's state, and sick/personal leave only if you explicitly turn it on, since it is not ordinarily payable on termination.
The whole payout, together with any payment in lieu of notice and any ETP or non-ETP lump sum, is taxed through the employment termination payment (ETP) tax tables. That run produces the STP lump sums, Lump Sum A (unused annual leave and leave loading, plus long service leave accrued from August 1978 onwards, when the reason is a redundancy or an invalidity) and Lump Sum B (long service leave accrued before August 1978), plus Lump Sum D, the tax-free part of a genuine redundancy or early retirement payment. Anything that is itself an ETP (redundancy, the ex-gratia etp_amount) comes back as one or more tax_summaries rows with their own taxable and tax-free components. The tables need the employee's date of birth; a termination calculated for an employee without one is rejected with 400.
Payment in lieu of notice is never part of Lump Sum A, however you send it. It is always an employment termination payment, and it appears in tax_summaries under an O code (or R where it belongs to a redundancy or invalidity), taxable in full. Classifying notice as unused leave would report the wrong lump sum to the tax office. The worked example below shows the split.
New Zealand
Annual holidays are valued in weeks, at the greater of average weekly earnings and ordinary weekly pay, as at the termination date. Alternative holidays (Holidays Act 2003 s.61) are valued separately, in days, at relevant daily pay. Outside manual mode Lightning Payroll also adds the statutory 8% holiday pay owing since the employee's last anniversary into non_etp_amount. New Zealand never pays out unused sick leave or long service leave; neither concept exists on the New Zealand side of a termination at all.
None of this goes through the ETP tax tables, which is a deliberate fork rather than an oversight: those tables pick an Australian calculator purely from the pay's processed date and have no New Zealand branch. Instead the whole termination payout is taxed as extra pay on the pay itself, the same treatment a bonus or a large one-off payment gets, producing nz_extra_pay_tax_breakdown with a separate tax figure for each component. nz_ytd_taxable lets you supply year-to-date earnings for the ACC earners' levy cap when Lightning Payroll has not held the whole tax year itself.
Errors
| Status | When it happens | What to do |
|---|---|---|
| 422 | Request body validation failed: an unknown field, a bad enum value, a negative amount on a field that requires ≥ 0, a missing required field, cessation_type_code sent with a reason other than TERMINATION, redundancy_tax_free_pilon_component sent without reason=REDUNDANCY, a notice field sent without is_payment_in_lieu_paid, both notice_hours and notice_lump_sum sent together, or a manual-only field sent without is_manual_leave_amounts. | Read the detail message, it names the offending field(s) and the rule; fix the payload shape and retry. |
| 400 | A field that belongs to the other country was sent; termination_date is before the employee's start_date; a required conditional field is missing or not positive (payment_in_lieu_type, notice_hours, notice_lump_sum in Australia, payment_in_lieu_amount in New Zealand); New Zealand's non_etp_amount sent without the manual flag; a negative Australian unused_holiday_amount; redundancy_tax_free_pilon_component exceeding the payment in lieu of notice paid; the calculation itself failed (for example, a missing date of birth for the ETP tables); or the employee ID does not exist. | Read the detail message, correct the named field or business rule, and retry. A rejected request leaves no pay behind, so a corrected retry is always safe. |
| 403 | The company has no active subscription for partner write access, or the caller's API scope does not include payroll writes. | Have the reseller or customer resolve billing, or use credentials with the payroll write scope. |
| 404 | GET/api/employees/{employee_id}/termination was called for an employee who has never been terminated. | Check the employee actually has a termination before reading one back; there is nothing to fetch until /terminate has succeeded at least once. |
| 409 | The employee is already deleted. | Restore them first with POST/api/employees/{employee_id}/restore, then terminate. |
| 409 | The employee is already terminated. | If they were re-hired, call POST/api/employees/{employee_id}/reinstate first, then terminate again. |
| 409 | The employee already has an unprocessed termination pay staged on a pay run. | The error names the blocking pay run. Process or remove the termination from that pay first; Lightning Payroll refuses a second termination while one is still unresolved, to avoid double-counting the leave-balance adjustment. |
| 409 | PUT/api/company/{company_id}/pays/{pay_id} was called on a pay that carries a termination. | A termination pay cannot be rebuilt in place. Remove the termination (delete the pay, which reverses it) and create a fresh one instead. |
A worked Australian example
A genuine redundancy, with notice paid as a lump sum and the leave payout amounts entered by hand. Every figure in the response below came back from this exact request.
{
// Last day worked, and the pay run this is paid in.
"termination_date": "2026-08-14",
"pay_date": "2026-08-19",
"reason": "REDUNDANCY",
// Five weeks of notice, paid as a lump sum rather than as hours.
"is_payment_in_lieu_paid": true,
"payment_in_lieu_type": "NOTICE_LUMP_SUM",
"notice_lump_sum": "7280.00",
// The redundancy payment itself.
"redundancy_amount": "24500.00",
// Leave payout amounts supplied rather than calculated.
"is_manual_leave_amounts": true,
"is_unused_holiday_paid": true,
"is_unused_leave_loading_paid": true,
"unused_holiday_amount": "6182.40",
"unused_leave_loading_amount": "1081.92",
"unused_lsl_post_august_1993": "3894.00"
}
The response, abbreviated to the calculated fields. Note what the API worked out that the request never mentioned: the cessation type, the payment in lieu amount, the total, and the whole tax treatment.
// 200 OK { "termination_date": "2026-08-14", "reason": "REDUNDANCY", "cessation_type_code": "R", // derived from the reason "payment_in_lieu_amount": 7280, // derived from notice_lump_sum "normal_earnings": 3891.2, // derived, feeds the ETP cap "unused_holiday_amount": 6182.4, "unused_leave_loading_amount": 1081.92, "unused_lsl_amount": 3894, "redundancy_amount": 24500, "total_amount": 42938.32, "total_non_etp_amount": 11158.32, "total_etp_amount": 31780, // Tax, split by the component it belongs to. "tax_amount": 5900, "unused_holiday_and_loading_tax_amount": 2324, "unused_lsl_tax_amount": 1246, // Reported lump sums. "lump_sum_a": 11158.32, "lump_sum_b": 0, "lump_sum_d": 24500, "etp_taxable_component": 7280, "etp_tax_free_component": 0, "etp_tax_withheld": 2330, "tax_summaries": [ { "etp_code": "R", "tax_withheld": 0, "taxable_component": 0, "tax_free_component": 0, "lump_sum_d": 24500 }, { "etp_code": "O", "tax_withheld": 2330, "taxable_component": 7280, "tax_free_component": 0, "lump_sum_d": 0 } ], "pay_id": 2, "pay_date": "2026-08-20", "is_pay_processed": true, "employee_id": 9, "employee_number": "EMP-1013", "is_employee_terminated": true }
It is worth following the money, because the split is the part integrators most often get wrong:
- The three unused leave amounts (6182.40 + 1081.92 + 3894.00) add up to 11,158.32, which is the non-ETP total and is reported as Lump Sum A, because unused leave paid on a redundancy is taxed at a flat rate rather than as ordinary income.
- The redundancy payment of 24,500 falls inside the tax-free limit for this employee's service, so it lands in Lump Sum D as an ETP with an
Rcode and no tax withheld. - The notice lump sum of 7,280 is not part of the redundancy. It is an ETP with an
Ocode, taxable in full, and 2,330 was withheld from it. - The ETP total of 31,780 is the redundancy plus the notice. Add the non-ETP 11,158.32 and you get the
total_amountof 42,938.32.
The request asked for pay_date of the 19th and the response reports the 20th. That is not a rounding bug: pay_date selects which pay run the final pay joins, and the response reports that run's own pay date. Read pay_date back from the response rather than assuming it equals what you sent, particularly if you show the payment date to a user.
A New Zealand example
An ordinary finish with notice paid in lieu and a redundancy component, leaving the leave amounts for Lightning Payroll to value from the employee's record. Notice how much shorter it is: there is no reason, no cessation type, and no leave payout flags, because New Zealand does not use them.
{
"termination_date": "2026-09-11",
"pay_date": "2026-09-17",
"is_payment_in_lieu_paid": true,
"payment_in_lieu_amount": "1200.00",
"redundancy_amount": "4000.00"
}
The response carries nz_extra_pay_tax_breakdown in place of every Australian lump sum and ETP field, with the tax attributed to each component of the payout:
// 200 OK, abbreviated { "termination_date": "2026-09-11", "payment_in_lieu_amount": 1200, "redundancy_amount": 4000, "unused_holiday_amount": 2884.6, "unused_alt_holiday_amount": 412.08, "non_etp_amount": 631.55, // the statutory 8% owing since the last anniversary "total_amount": 9128.23, "tax_amount": 2830.75, "nz_extra_pay_tax_breakdown": { "components": { "unused_holiday": 894.23, "alt_holiday": 127.74, "redundancy": 1240, "other": 568.78 }, "total_tax": 2830.75, "taxed_at_low_rate": false }, "is_employee_terminated": true }
taxed_at_low_rate is worth surfacing in your own interface: it is what sets the lump sum indicator on the Employment Information return, so it tells you how the payout was treated for payday filing.
11Reinstate, delete and restore
The three ways back. Two of them undo something, and the third undoes the other thing.
Reinstating a rehire
The body is optional. Send a start_date for the new period of employment, or send nothing and today is used.
{
"start_date": "2026-09-01"
}
| Reinstating | Effect |
|---|---|
| Clears | is_terminated and termination_date. |
| Sets | start_date to the date you sent, or today. |
| Keeps | Year-to-date figures, the payroll number, pay history, bank accounts, super details, and the previous termination record. |
That last one surprises people. The termination that happened really did happen: it produced a final pay and it was reported. Reinstating does not rewrite history, so GET /api/employees/{id}/termination keeps returning that record afterwards, with is_employee_terminated now false. Read that field, not the presence of the record, to decide whether someone currently works there.
Two rules will reject a reinstatement:
- The new start date cannot be earlier than the termination date. Employment cannot resume before it ended.
- An unprocessed termination pay sitting on the employee blocks it with 409. Deal with that pay first, then reinstate.
Deleting and restoring a record
Delete is a soft delete returning 204 with no body. The record leaves the ordinary lists, and its pays, payslips, history and payroll number all stay exactly where they were. It is reversible with /restore, which returns the employee.
The payroll number staying reserved is deliberate: a deleted employee still holds their number, so reusing it for someone else is refused. If you are cleaning up a bad import and want the codes back, restore the record and renumber it rather than expecting the code to free itself.
Restore re-checks the customer's employee limit, because bringing a record back adds to the count. If they have filled their allowance since the delete, restore fails with 403 and the fix is a plan change, not a retry.
Terminating and deleting the same employee is fine, and the calls are independent. /reinstate deals with employment, /restore deals with visibility, and each ignores the other. If a record is both terminated and deleted, you need both calls to get back to an ordinary active employee.
12Employee webhooks
Register a URL once and we tell you when an employee changes, whoever changed it: your integration, the payroll clerk in the app, or the employee in their portal.
Every write to an employee record can raise a webhook delivery: creates, edits, terminations, reinstatements, restores and deletes all flow through the same three event types. There is no separate event per action, so knowing what actually happened means reading the payload, not just the event name.
There is no employee.terminated event. A termination is just another change to the same employee row, so it arrives as an ordinary employee.updated delivery. To detect a termination, diff the incoming data for is_terminated flipping to true together with termination_date being set. The delivery itself carries no financial detail: for the final pay, leave payout, tax withheld or cessation reason, call GET/api/employees/{employee_id}/termination and read it back.
The three employee events
| Event | Fires when | What the payload carries |
|---|---|---|
employee.created | A new employee row is committed for the first time. | The full employee read model as it stands immediately after creation. |
employee.updated | An existing employee row is committed with at least one real field change, and that change does not soft-delete the employee. | The full employee read model after the change, not a diff of what moved. A termination surfaces here (see above), as does a reinstatement or restore. |
employee.deleted | An employee is soft-deleted (is_deleted flips from false to true). | The full employee read model as it stood at the moment of deletion, with is_deleted now true. |
What a delivery looks like
Every delivery is one JSON object, the same envelope regardless of event type:
// Envelope wrapping every webhook payload { "id": "3fa8a3d2-9e35-4d9a-9d3b-9d8f7a2b6c1e", "type": "employee.updated", "createdAt": "2026-08-20T03:14:07.512331", "customerId": 40213, "entity": { "type": "employee", "id": 8842 }, "data": { ... } }
data is the full employee read model, the same shape returned by GET/api/employees/{employee_id}, not a partial diff of the fields that changed. A soft delete does not arrive as an employee.updated payload with a flag flipped inside it: it is reclassified to employee.deleted before it ever reaches your endpoint, so filtering on the event type alone is enough to catch it. Note that the envelope's own id is refreshed every time the payload is (re)built, including when a queued delivery is coalesced with newer state (see below); it is not stable the way the X-LP-Delivery-Id header is.
Coalescing
A webhook delivery represents an employee's state at a point in time, not a row in an audit log. Two collapsing rules apply before anything reaches your endpoint:
- Create immediately followed by update, in the same commit. If an employee row is created and then edited again before that transaction commits, only one event is raised,
employee.created, carrying the state after the edit. The intermediate "just created" state is never delivered as its own event. - Repeated edits before the previous delivery has been attempted. If an employee changes again while an earlier delivery for that employee is still queued and unsent, the queued delivery is rewritten in place with the newer state rather than a second delivery being queued behind it. This collapsing is matched on the event type as well as the employee, so it only merges repeated changes that raise the same event. A create still waiting to be sent when the same employee is edited in a later commit leaves you with two unsent deliveries, one
employee.createdand oneemployee.updated, rather than one merged delivery. Once a delivery has been attempted at least once, this no longer applies: further changes queue as their own, separate delivery, so you can legitimately receive more than one delivery for the same employee if an earlier attempt already went out.
The consequence for an integrator: do not build a per-change audit trail on top of webhooks. It is not one. A gap between two deliveries' data can hide any number of intermediate edits, and one delivery is not guaranteed to correspond to one user action. Treat each delivery as "this employee's state as of now"; if you need a full history of what changed and when, keep it in your own change log rather than inferring it from webhook traffic.
Verifying a delivery
Every delivery carries these headers:
| Header | Value |
|---|---|
X-LP-Event | The event type, e.g. employee.updated. |
X-LP-Delivery-Id | The delivery's own id, stable across retries of that same delivery. |
X-LP-Timestamp | Unix epoch seconds at send time, as a string. |
X-LP-Signature | Hex-encoded HMAC-SHA256 signature, see below. |
The signed string is {timestamp}.{raw body}, a literal dot joining the exact value of X-LP-Timestamp to the exact bytes of the request body, HMAC-SHA256'd with your endpoint's secret:
# timestamp and raw_body must be the exact header value and exact request body bytes as received
import hmac
import hashlib
def verify_signature(secret: str, timestamp: str, raw_body: bytes, signature: str) -> bool:
signed = timestamp.encode("utf-8") + b"." + raw_body
expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
The critical detail is raw body. Verify against the exact bytes your server received, before any JSON parsing or re-encoding. Re-serializing the parsed body, even with logically identical data, produces a different key order, whitespace or escaping, and therefore a different byte string and a signature mismatch.
Retries
A delivery gets up to 8 attempts. Any 2xx response is the only thing that counts as success; a non-2xx response, a network error, or no response inside the 10 second request timeout all count as a failed attempt and schedule a retry. Redirects are not followed.
The wait before each attempt doubles, starting at 30 seconds:
| Attempt | Wait before it |
|---|---|
| 1 | Immediate |
| 2 | 30s |
| 3 | 1m |
| 4 | 2m |
| 5 | 4m |
| 6 | 8m |
| 7 | 16m |
| 8 (final) | 32m |
If attempt 8 fails, the delivery is marked failed and nothing further is sent. Total elapsed time across all 8 attempts is a little over an hour, so a listener that is briefly unavailable (a deploy, a restart) has roughly that long to come back before the delivery gives up on it.
A delivery's status is one of:
| Status | Meaning |
|---|---|
| pending | Queued, not yet attempted, or re-armed with newer state before its first attempt. |
| sending | An attempt is currently in flight. One stuck here for more than 5 minutes (a worker that died mid-attempt) is automatically moved to retry or failed. |
| delivered | A 2xx response was received. Terminal, no further attempts. |
| retry | The last attempt failed and another is scheduled. |
| failed | All 8 attempts are used up. Terminal, no further attempts. |
| skipped | The destination endpoint was deleted or deactivated before this delivery could be sent. Terminal. |
Reconciliation
If your integration only reacts to webhooks, three shipped behaviours can leave you holding stale or incomplete state: nothing enforces a replay window on our end, so a delivery can still land and look valid well after the change it describes; deliveries coalesce, so intermediate states are silently skipped rather than delivered; and a delivery that never gets a 2xx inside its 8 attempts simply stops, with nothing ever reaching you for that particular change.
The reliable pattern is to treat a webhook purely as a prompt to look, not as the update itself: on receipt, call GET/api/employees/{employee_id} and use that response as your source of truth, rather than acting on data in the payload for anything that matters downstream. This also sidesteps ordering problems: if two deliveries for the same employee ever arrive out of sequence, reading the employee back gives you its current state regardless of which delivery triggered the read.
Registering an endpoint, choosing which events it receives, rotating its secret, and pausing it without losing delivery history are covered in the Webhooks Guide.
13Endpoint reference
Every employee lifecycle endpoint on the partner surface, with the scope each one needs.
| Method | Path | Purpose | Scope |
|---|---|---|---|
| Employees | |||
| GET | /api/company/{company_id}/employees | List employees for a company; filter by status (active / terminated / deleted / all). | payroll.read |
| PUT | /api/company/{company_id}/employees/create | Batch-create employees, one result per input item. | payroll.write |
| PATCH | /api/company/{company_id}/employees/update | Batch-update employees, matched by employee id or payroll number. | payroll.write |
| GET | /api/employees/{employee_id} | Get one employee by its Lightning Payroll id. | payroll.read |
| GET | /api/employees/payroll-number/{employee_number} | Get one employee by its caller-assigned payroll number. | payroll.read |
| GET | /api/company/{company_id}/leave-requests | List employee leave requests. AU only, employee-portal feature, read-only. | payroll.read |
| Lifecycle | |||
| DELETE | /api/employees/{employee_id} | Soft-delete an employee. Reversible, keeps pay history and payroll number. | payroll.write |
| POST | /api/employees/{employee_id}/restore | Undo a delete. | payroll.write |
| POST | /api/employees/{employee_id}/terminate | Terminate an employee: creates the final pay and calculates leave payout and tax. | payroll.write |
| GET | /api/employees/{employee_id}/termination | Read back an employee's most recent termination. | payroll.read |
| POST | /api/employees/{employee_id}/reinstate | Undo a termination for a re-hire, keeping YTD figures and payroll number. | payroll.write |
| Bank accounts | |||
| GET | /api/employees/{employee_id}/bank-accounts | List an employee's bank accounts, default account first. | payroll.read |
| POST | /api/employees/{employee_id}/bank-accounts | Add a bank account. Maximum 10 per employee. | payroll.write |
| PATCH | /api/employees/{employee_id}/bank-accounts/{bank_account_id} | Partially update a bank account. | payroll.write |
| DELETE | /api/employees/{employee_id}/bank-accounts/{bank_account_id} | Delete a bank account. | payroll.write |
| Superannuation (AU) | |||
| GET | /api/employees/{employee_id}/super-funds | List an employee's active super funds. AU only. | payroll.read |
| DELETE | /api/employees/{employee_id}/super-funds/{super_fund_id} | Delete a super fund. AU only. | payroll.write |
| Allowances | |||
| GET | /api/employees/{employee_id}/allowances | List an employee's own and company-attached allowances. | payroll.read |
| GET | /api/employees/{employee_id}/allowances/{allowance_id} | Get one employee allowance. | payroll.read |
| POST | /api/employees/{employee_id}/allowances | Create an employee-specific allowance. | payroll.write |
| PATCH | /api/employees/{employee_id}/allowances/{allowance_id} | Update an employee-specific allowance. | payroll.write |
| DELETE | /api/employees/{employee_id}/allowances/{allowance_id} | Delete an employee-specific allowance. | payroll.write |
| PUT | /api/employees/{employee_id}/company-allowances/{allowance_id} | Attach a company-wide allowance to an employee. Idempotent. | payroll.write |
| DELETE | /api/employees/{employee_id}/company-allowances/{allowance_id} | Detach a company-wide allowance from an employee. | payroll.write |
| GET | /api/company/{company_id}/allowances | List a company's allowance definitions. | payroll.read |
| GET | /api/company/{company_id}/allowances/{allowance_id} | Get one company allowance definition. | payroll.read |
| POST | /api/company/{company_id}/allowances | Create a company-wide allowance definition. | payroll.write |
| PATCH | /api/company/{company_id}/allowances/{allowance_id} | Update a company allowance definition. Replaces its whole attached-employee set. | payroll.write |
| DELETE | /api/company/{company_id}/allowances/{allowance_id} | Delete a company allowance definition. | payroll.write |
| Lookups | |||
| GET | /api/employment-types | List available employment types. | payroll.read |
| GET | /api/employment-tenures | List available employment tenures. | payroll.read |
| GET | /api/employees/{employee_id}/pay-rates | List pay-rate options available to an employee. | payroll.read |
This table is a quick reference. The OpenAPI schema at /openapi.json is authoritative and machine readable: use it to generate a client, confirm a field name, or check a response shape rather than this page.
14Related guides
This guide assumes you already have a token and stops where payroll processing begins. These pick up the rest.