Lightning Payroll Employee Lifecycle Back to API documentation →
Partner integration guide

Hire, configure, pay, end, rehire.

Everything a partner needs to run an employee's whole working life through the Lightning Payroll API: the create payload that actually passes validation in both countries, the sub-resources that make an employee payable, the termination call that produces a compliant final pay, and the webhooks that tell you when any of it changed underneath you.

2 countries 4 lifecycle operations 3 employee webhook events 2 OAuth scopes API version current
Not in payroll no record yet Active is_terminated: false is_deleted: false Terminated is_terminated: true termination_date set Deleted is_deleted: true hidden, history kept PUT employees/create POST /terminate POST /reinstate DELETE POST /restore DELETE

Employment state and record visibility are two separate axes. Terminating ends the job; deleting hides the record. Neither implies the other.

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.

OperationCallWhat it changesUse 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.

ScopeGrants
payroll.readEvery GET in this guide: reading employees, bank accounts, super funds, allowances, terminations.
payroll.writeEvery 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

EndpointsLimit
Employee create, update, delete, restore, reinstate, and the read endpoints25 requests per minute
Termination and the pay endpoints60 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 number on 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}/termination returns 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 data key.

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.

PUT/api/company/{company_id}/employees/create

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:

StatusMeaning
201Every employee in the batch was created.
207Some were created and some were not. Walk the array and look at errors on each entry.
422The 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.

PUT /api/company/412/employees/create
[
  {
    "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.

01

Create the employee

Identity, address, the tax declaration for their country, and how often they are paid. Keep the id from the response.

02

Add a bank account

Required if they are paid by bank transfer, which is nearly everyone. Section 05.

03

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.

04

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.

05

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.

06

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.

lightningpayroll.com.au/admin/employees
The result of the payload above, as your customer sees it. This whole roster was created through the create endpoint in
The result of the payload above, as your customer sees it. This whole roster was created through the create endpoint in one call, then one employee was terminated and one soft-deleted through the lifecycle endpoints. Every value on the right came off the JSON: the honorific, both given names, the gender, the payroll number in EMP-1004, the date of birth and the contact details. "Include Terminated?" is ticked here, which is how a clerk sees former staff alongside current ones.

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

FieldTypeRequired on createNotes
first_namestring, 1 to 60 charactersrequired
middle_namestring, up to 60 charactersoptional
last_namestring, 1 to 60 charactersrequired
date_of_birthdate, YYYY-MM-DDrequiredRejected if it falls in the future.
genderstring, one of MALE, FEMALE, INDETERMINATE, UNKNOWNoptionalDefaults to UNKNOWN if omitted.
honorificstring, up to 18 charactersoptionalE.g. Mr, Ms, Dr.
numberstring, up to 60 charactersoptionalYour 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

FieldTypeRequired on createNotes
address1string, 1 to 100 charactersrequired
address2string, up to 100 charactersoptional
citystring, 1 to 60 charactersrequired
statestring, 2 to 3 characters, upper-casedrequiredFree-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.
countrystring, up to 2 characters, upper-casedrequiredDrives which required-field branch runs on create. See the callout above.
postcodestring, exactly 4 digitsrequired

Contact and next of kin

The employee's own contact details, plus an optional emergency contact.

FieldTypeRequired on createNotes
email_addressvalid email addressoptional
phone_homestring, empty or 8 to 15 characters (digits, spaces, +, (, ), -)optional
phone_mobilesame shape as phone_homeoptional
phone_medicalsame shape as phone_homeoptionalContact number for medical purposes.
kin_namestring, up to 60 charactersoptional
kin_relationshipstring, up to 60 charactersoptional
kin_address1string, up to 100 charactersoptional
kin_address2string, up to 100 charactersoptional
kin_citystring, up to 60 charactersoptional
kin_statestring, 2 to 3 characters, upper-casedoptional
kin_postcodestring, exactly 4 digitsoptional
kin_phone_homesame shape as phone_homeoptional
kin_phone_worksame shape as phone_homeoptional
kin_phone_mobilesame shape as phone_homeoptional
kin_notesfree text, no length limit enforcedoptional

Employment

FieldTypeRequired on createNotes
start_datedate, YYYY-MM-DDrequired
employment_statusstring, one of Full-time, Part-time, Casual, Labour Hire (matched case and hyphen insensitively)requiredSets 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_typeinteger id, or the option's name (string), from GET/api/employment-typesoptionalBuilt-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_tenureinteger id, or the option's name (string), from GET/api/employment-tenuresoptionalBuilt-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_recipientbooleanoptionalDefaults 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.
positionstring, up to 60 charactersoptionalJob title, shown on the payslip.
departmentstring, up to 60 charactersoptional

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.

FieldTypeRequired on createNotes
is_super_only_contractorbooleanoptionalAU 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_numberstringconditionalAU 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_datedate, YYYY-MM-DDoptionalAU Date the employee signed their TFN declaration.
is_foreign_residentbooleanconditionalAU Required on every Australian create, including a super-only contractor.
has_claimed_tax_free_thresholdbooleanconditionalAU Required on every Australian create, including a super-only contractor.
has_stsl_liabilitybooleanconditionalAU 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_heldbooleanconditionalAU 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_appliesbooleanoptionalAU If true, the Working Holiday Maker tax scale is used instead of the standard one.
include_email_and_phone_in_stpbooleanoptionalAU Defaults to false. Whether the employee's email and phone number are included in Single Touch Payroll reports sent to the ATO.
abnstringoptionalAU The employee's own Australian Business Number, for a contractor who bills the company.

New Zealand tax declaration

FieldTypeRequired on createNotes
nz_tax_codestring, 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, WTconditionalNZ 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_numberstringconditionalNZ 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_loanbooleanoptionalNZ Forced to false if no_declaration is true.
no_declarationbooleanoptionalNZ 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

FieldTypeRequired on createNotes
pay_methodstring, one of CASH, DIRECT BANK ENTRY, OTHERoptionalDefaults to DIRECT BANK ENTRY if omitted.
pay_periodstring, one of WEEKLY, FORTNIGHTLY, MONTHLYoptionalDefaults to WEEKLY if omitted.
pay_rate_per_hourdecimaloptionalUsed to calculate gross pay from hours worked.
standard_hours_per_daydecimaloptionalE.g. 7.6. Feeds leave accrual and RDO calculations.
standard_days_per_weekintegeroptionalE.g. 5.
stp_employment_statusstring, one of C, P, FoptionalDefaults 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)

FieldTypeRequired on createNotes
default_employee_fund_member_numberstring, up to 20 charactersoptionalAU
default_employee_fund_usistring, up to 20 charactersoptionalAU 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_ratedecimaloptionalAU 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_ratedecimaloptionalAU The legally required minimum rate, e.g. 0.105 for 10.5%.
is_super_enabledbooleanoptionalAU If false, no super is calculated for this employee regardless of the two rates above.
is_super_age_threshold_enabledbooleanoptionalAU Whether the employee's age is factored into super eligibility.
super_based_onstring, OTE or GROSSoptionalAU Whether super is calculated on Ordinary Time Earnings (recommended) or Gross Pay.

KiwiSaver and ESCT (NZ)

FieldTypeRequired on createNotes
kiwisaver_employee_ratedecimaloptionalNZ Fraction, e.g. 0.03 for 3%. Falls back to Lightning Payroll's current default KiwiSaver rate if omitted.
kiwisaver_employer_ratedecimaloptionalNZ Same, employer-paid side.
kiwisaver_status_codestringoptionalNZ Defaults to AE (auto-enrol) if omitted.
kiwisaver_existing_actionstringoptionalNZ Only meaningful when the employee is an existing KiwiSaver member.
kiwisaver_cec_obligationstringoptionalNZ E.g. NONE.
esct_ratedecimaloptionalNZ Employer Superannuation Contribution Tax rate, e.g. 0.105 for 10.5%. Defaults to 0.105 if omitted.
employer_contrib_tax_methodstringoptionalNZ
employer_contrib_paye_fractiondecimaloptionalNZ

Leave accrual settings

FieldTypeRequired on createNotes
is_leave_enabledbooleanoptionalDefaults to true. If false, no leave accrues for this employee at all.
include_leave_loading_in_superbooleanoptionalDefaults to false. Whether leave loading counts toward superannuation.
accrue_leave_on_hours_workedbooleanoptionalIf true, leave accrues pro rata on hours actually worked. If false, it accrues by the pay period instead.
accrue_leave_on_overtime_hoursbooleanoptionalWhether overtime hours also contribute to leave accrual.
accrue_holiday_leave_per_hourdecimaloptionalPer-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_hourdecimaloptionalPer-hour accrual rate for sick/personal leave. Defaults to 0.038462 (10 days a year) if omitted.
accrue_lsl_per_hourdecimaloptionalPer-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_yearintegeroptionalE.g. 10.
num_holiday_leave_days_per_yearintegeroptionalE.g. 20.
is_lsl_enabledbooleanoptional
lsl_x_yearsintegeroptionalYears of service required before the employee qualifies for long service leave.
lsl_accrued_x_yearsintegeroptionalWeeks of long service leave accrued for every lsl_x_years years of service.
hourly_amount_for_workers_comp_leavedecimaloptionalDefaults to 0.00 if omitted.
hourly_amount_for_paid_parental_leavedecimaloptionalDefaults to 0.00 if omitted.
rdo_hoursdecimaloptionalCurrent accrued Rostered Days Off balance, in hours. Unlike the leave-hour balances covered below, this one is writable directly.
toil_hoursdecimaloptionalCurrent accrued Time Off In Lieu balance, in hours. Also writable directly.
leave_loading_percentagedecimaloptionalE.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.

FieldTypeRequired on createNotes
primary_bank_bsbstring, empty or 6 digits with an optional hyphen after the third digit (123456 or 123-456)optional
primary_bank_account_numberstring, up to 12 charactersoptional
primary_bank_account_namestring, up to 32 charactersoptional
secondary_bank_bsbsame shape as primary_bank_bsboptional
secondary_bank_account_numbersame shape as primary_bank_account_numberoptional
secondary_bank_account_namesame shape as primary_bank_account_nameoptional
secondary_bank_referencestring, up to 18 charactersoptionalLodgement reference put on the secondary account's deposit.
secondary_bank_amount_per_perioddecimaloptionalFixed 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.

FieldTypeRequired on createNotes
employee_portal_activebooleanoptionalThe 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_negativebooleanoptionalOnly 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:

FieldTypeRequired on createNotes
payslip_leave_unitsstring, HOURS or DAYSoptionalThe unit leave balances are shown in on the payslip.
payslip_notestringoptionalFree-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.

FieldNotes
holiday_leave_hours read-onlyCurrent annual/holiday leave balance, in hours.
sick_leave_hours read-onlyCurrent personal/sick leave balance, in hours.
lsl_leave_hours read-onlyCurrent long service leave balance, in hours.
rdo_days read-onlyCurrent 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-onlyCurrent time-off-in-lieu balance in days. toil_hours is writable.
pay_period_gross, annual_gross, current_ytd_gross read-onlyPay figures derived from the employee's current settings and pay history.
period_student_loan_cir, period_student_loan_bor read-only NZStudent 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 NZKiwiSaver opt-out history.
has_help_liability read-only AUDeprecated, HELP-only legacy column. Send has_stsl_liability instead, which also covers VSL, SFSS, SSL, ABSTUDY SSL and TSL.
is_australian_resident read-onlyDerived 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 AUSingle Touch Payroll reporting metadata, computed from the employee's other settings.
default_employee_fund_name read-only AUThe name of the fund identified by default_employee_fund_usi, which is writable.
username read-onlyThe employee's online portal login username.
standard_hours_per_week read-onlyCalculated 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.

lightningpayroll.com.au/admin/employees/tax/tax_settings
The Australian tax declaration. The four booleans that create requires are the checkboxes here, and getting them wrong c
The Australian tax declaration. The four booleans that create requires are the checkboxes here, and getting them wrong changes the tax withheld from the first pay onward.
lightningpayroll.com.au/admin/employees/pay_settings/pay_rates
Pay rates and standard hours. pay_rate_per_hour, standard_hours_per_day and standard_days_per_week drive what a pay defa
Pay rates and standard hours. pay_rate_per_hour, standard_hours_per_day and standard_days_per_week drive what a pay defaults to.
lightningpayroll.com.au/admin/employees/leave/leave_settings
Leave accrual settings. You can write the accrual configuration; the balances themselves are read-only and grow as pays
Leave accrual settings. You can write the accrual configuration; the balances themselves are read-only and grow as pays are processed.
lightningpayroll.com.au/admin/employees/details/online_portal
The portal permission flags. Each allow_ field is one checkbox here, and each one hands a decision to the employee inste
The portal permission flags. Each allow_ field is one checkbox here, and each one hands a decision to the employee instead of the payroll clerk.

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.

GET/api/employees/{employee_id}/bank-accounts POST/api/employees/{employee_id}/bank-accounts PATCH/api/employees/{employee_id}/bank-accounts/{bank_account_id} DELETE/api/employees/{employee_id}/bank-accounts/{bank_account_id}

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) and nz_suffix (2 to 4 digits).
  • Send all four together. Re-keying a New Zealand account clears any Australian values it held.

Fields

FieldTypeRequiredNotes
account_namestring, up to 32 charactersrequiredThe name on the account.
bsbstringconditionalAU Required with account_number for an Australian company.
account_numberstringconditionalAU String, not a number, so leading zeros are preserved.
nz_bank_id, nz_branch, nz_account_base, nz_suffixstringsconditionalNZ All four required together for a New Zealand company.
rankinteger, 0 or moreoptionalWhere 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.
amountdecimal, 2 placesconditionalRequired, 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_referencestring, up to 18 charactersoptionalWhat 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.

POST /api/employees/8842/bank-accounts
// 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 rank you send, because an employee must always have exactly one account taking the remainder. Send "rank": 1 on 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_accounts on the employee before you offer bank editing in your own interface.
  • A partial update re-validates the whole identity. Sending just a new account_number re-checks it against the BSB, so a valid pair cannot become an invalid one.
lightningpayroll.com.au/admin/employees/details/bank_accounts
Two accounts on one employee, written through the sub-resource: the everyday account taking the remainder and a fixed sa
Two accounts on one employee, written through the sub-resource: the everyday account taking the remainder and a fixed savings split with its own statement reference.

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_rate and kiwisaver_employer_rate.
  • kiwisaver_status_code, kiwisaver_existing_action and kiwisaver_cec_obligation.
  • esct_rate for employer superannuation contribution tax, plus employer_contrib_tax_method and employer_contrib_paye_fraction.
GET/api/employees/{employee_id}/super-funds DELETE/api/employees/{employee_id}/super-funds/{super_fund_id}

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.

lightningpayroll.com.au/admin/employees/super/superannuation_fund
The Australian super fund screen. default_employee_fund_usi and default_employee_fund_member_number on the employee payl
The Australian super fund screen. default_employee_fund_usi and default_employee_fund_member_number on the employee payload populate the fund and membership shown here.

07Allowances

Two kinds: one employee's own allowance, and a company-wide allowance attached to whoever should get it. Both are here.

GET/api/employees/{employee_id}/allowances POST/api/employees/{employee_id}/allowances PATCH/api/employees/{employee_id}/allowances/{allowance_id} DELETE/api/employees/{employee_id}/allowances/{allowance_id} PUT/api/employees/{employee_id}/company-allowances/{allowance_id}

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:

CodeCategory
CDCents per kilometre
ADAward transport
LDLaundry
MDOvertime meals
RDDomestic and overseas travel or accommodation
TDTools
KNTasks
QNQualifications and certificates
ODOther
POST /api/employees/8843/allowances
{
  "description": "Tool allowance",
  "amount": "26.50",
  "allowance_category": "TD"
}
  • Attaching a company allowance is idempotent. PUT the 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 PATCH takes an optional propagate_description_to_history flag 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.

GET/api/company/{company_id}/employees GET/api/employees/{employee_id} GET/api/employees/payroll-number/{employee_number}

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.

statusReturns
omittedEveryone not deleted. Current staff and former staff together.
activeCurrent staff only.
terminatedFormer staff only.
deletedSoft-deleted records only.
allEverything, 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.

PATCH/api/company/{company_id}/employees/update

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

PATCH /api/company/412/employees/update
[
  {
    "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_period is 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 read errors.pay_period on 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.

POST/api/employees/{employee_id}/terminate GET/api/employees/{employee_id}/termination

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.

FieldTypeRequiredNotes
termination_datedate, YYYY-MM-DDrequiredThe 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_datedate, YYYY-MM-DDrequiredSelects 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_amountsbooleanoptional, default falseSet 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_paidbooleanoptionalWhether 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_amountdecimal, ≥ 0optionalAU 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_amountdecimal, ≥ 0optional (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_amountdecimaloptional, manual onlyUnused 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

FieldTypeRequiredNotes
reasonenumoptional, default TERMINATIONWhy 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_codeenumconditionalOnly 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_typeenumconditionalRequired (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_hoursdecimal, ≥ 0conditionalRequired, 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_sumdecimal, ≥ 0conditionalRequired, and must be more than zero, when payment_in_lieu_type is NOTICE_LUMP_SUM. Mutually exclusive with notice_hours.
is_unused_holiday_paidbooleanoptionalPay 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_paidbooleanoptionalPay 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_paidbooleanoptionalPay 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_paidbooleanoptional, default falsePay 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_componentdecimal, ≥ 0conditionalOnly 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_amountdecimal, ≥ 0optionalAnother payout amount that is an employment termination payment, such as an ex-gratia payment or golden handshake.
unused_leave_loading_amountdecimal, ≥ 0optional, manual onlyUnused leave loading payout, entered directly instead of calculated.
unused_sick_amountdecimal, ≥ 0optional, manual onlyUnused sick/personal leave payout, entered directly.
unused_lsl_pre_august_1978decimal, ≥ 0optional, manual onlyUnused long service leave accrued before August 1978, which is taxed at its own rate.
unused_lsl_august_1978_to_august_1993decimal, ≥ 0optional, manual onlyUnused long service leave accrued between August 1978 and August 1993.
unused_lsl_post_august_1993decimal, ≥ 0optional, manual onlyUnused 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_earningsdecimal, ≥ 0optional, manual onlyThe 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_summariesarray of objectsoptional, manual onlyHand-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:

FieldTypeRequiredNotes
etp_codeenumrequiredSee Enumerations.
tax_withhelddecimal, ≥ 0optionalTax withheld from this ETP row.
taxable_componentdecimal, ≥ 0optionalTaxable component of this ETP row.
tax_free_componentdecimal, ≥ 0optionalTax-free component of this ETP row.
lump_sum_ddecimal, ≥ 0optionalLump 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.

FieldTypeRequiredNotes
payment_in_lieu_amountdecimal, ≥ 0conditionalPayment 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_amountdecimal, ≥ 0optional, manual onlyUnused 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_taxabledecimal, ≥ 0optionalTaxable 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_amount
  • unused_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):

ValueMeaning
TERMINATIONAn ordinary termination.
INVALIDITYThe employee can no longer work through ill health.
DEATH_DEPENDENTDeath of the employee, benefits paid to a dependant.
DEATH_NON_DEPENDENTDeath of the employee, benefits paid to a non-dependant.
DEATH_ESTATEDeath of the employee, benefits paid to the estate.
REDUNDANCYA 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):

ValueMeaning
VVoluntary cessation: resignation or retirement initiated by the employee.
FDismissal: employer-initiated termination.
CContract cessation: natural conclusion of a limited-term engagement.
TTransfer 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:

ValueMeaning
NOTICE_HOURSPay out notice_hours at the employee's hourly rate.
NOTICE_LUMP_SUMPay 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):

ValueMeaning
RRedundancy, invalidity or early retirement.
OOther: an ex-gratia payment or golden handshake.
SA split of a type R payment.
PA split of a type O payment.
DDeath benefit paid to a dependant.
NDeath benefit paid to a non-dependant.
BA split of a type N payment.
TDeath 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

StatusWhen it happensWhat to do
422Request 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.
400A 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.
403The 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.
404GET/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.
409The employee is already deleted.Restore them first with POST/api/employees/{employee_id}/restore, then terminate.
409The employee is already terminated.If they were re-hired, call POST/api/employees/{employee_id}/reinstate first, then terminate again.
409The 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.
409PUT/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.

POST /api/employees/9/terminate
{
  // 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 R code and no tax withheld.
  • The notice lump sum of 7,280 is not part of the redundancy. It is an ETP with an O code, 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_amount of 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.

POST /api/employees/6104/terminate
{
  "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

POST/api/employees/{employee_id}/reinstate

The body is optional. Send a start_date for the new period of employment, or send nothing and today is used.

POST /api/employees/8851/reinstate
{
  "start_date": "2026-09-01"
}
ReinstatingEffect
Clearsis_terminated and termination_date.
Setsstart_date to the date you sent, or today.
KeepsYear-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/api/employees/{employee_id} POST/api/employees/{employee_id}/restore

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.

lightningpayroll.com.au/admin/employees/reinstate
The Reinstate screen, showing the employee terminated by the worked example in section 10. Your reinstate call is the sa
The Reinstate screen, showing the employee terminated by the worked example in section 10. Your reinstate call is the same operation a payroll clerk performs here, and the Start Date field is the optional start_date in the request body: leave it out of your JSON and you get today, exactly as this screen defaults to today.

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

EventFires whenWhat the payload carries
employee.createdA new employee row is committed for the first time.The full employee read model as it stands immediately after creation.
employee.updatedAn 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.deletedAn 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.created and one employee.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:

HeaderValue
X-LP-EventThe event type, e.g. employee.updated.
X-LP-Delivery-IdThe delivery's own id, stable across retries of that same delivery.
X-LP-TimestampUnix epoch seconds at send time, as a string.
X-LP-SignatureHex-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:

AttemptWait before it
1Immediate
230s
31m
42m
54m
68m
716m
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:

StatusMeaning
pendingQueued, not yet attempted, or re-armed with newer state before its first attempt.
sendingAn 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.
deliveredA 2xx response was received. Terminal, no further attempts.
retryThe last attempt failed and another is scheduled.
failedAll 8 attempts are used up. Terminal, no further attempts.
skippedThe 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.

lightningpayroll.com.au/admin/manage-api
The delivery log in the API Management console. Every employee event is recorded with its attempt count, status and the
The delivery log in the API Management console. Every employee event is recorded with its attempt count, status and the exact payload that was signed, which is the fastest way to settle whether a delivery left our side.

13Endpoint reference

Every employee lifecycle endpoint on the partner surface, with the scope each one needs.

MethodPathPurposeScope
Employees
GET/api/company/{company_id}/employeesList employees for a company; filter by status (active / terminated / deleted / all).payroll.read
PUT/api/company/{company_id}/employees/createBatch-create employees, one result per input item.payroll.write
PATCH/api/company/{company_id}/employees/updateBatch-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-requestsList 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}/restoreUndo a delete.payroll.write
POST/api/employees/{employee_id}/terminateTerminate an employee: creates the final pay and calculates leave payout and tax.payroll.write
GET/api/employees/{employee_id}/terminationRead back an employee's most recent termination.payroll.read
POST/api/employees/{employee_id}/reinstateUndo a termination for a re-hire, keeping YTD figures and payroll number.payroll.write
Bank accounts
GET/api/employees/{employee_id}/bank-accountsList an employee's bank accounts, default account first.payroll.read
POST/api/employees/{employee_id}/bank-accountsAdd 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-fundsList 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}/allowancesList 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}/allowancesCreate 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}/allowancesList 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}/allowancesCreate 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-typesList available employment types.payroll.read
GET/api/employment-tenuresList available employment tenures.payroll.read
GET/api/employees/{employee_id}/pay-ratesList 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.