Lightning Payroll

API Documentation

Getting Started

Welcome to the Lightning Payroll API. We use the OAuth 2.0 Authorization Code flow so your app can access customer payroll data securely and in a way developers already know.

Machine-readable schema endpoints:
/schema/openapi.json
/schema/components/index.json
/schema/operations/index.json
/schema/endpoints/create-pays-for-date.json
/schema/components/PayCreate.json
/schema/components/PayCreate.json?resolved=true
Integration guides:
OAuth Authentication Guide
API Admin Setup and Management Guide
Webhooks Guide (events, signing, retries)
API Branding & Co-Branding Guide
Company Redirect Guide
Partner Checkout Admin Endpoints Guide (includes free trials)
Lightning Payroll Partner Program

Environments

EnvironmentBase URLNotes
Production AUhttps://api.lightningpayroll.com.auLive AU-hosted customer data
Production NZhttps://api.lightningpayroll.co.nzLive NZ-hosted customer data
DevelopmentIssued with your partner credentialsAU and NZ sandbox hosts, plus their matching app hosts, are supplied when your partner account is set up.
Current docs host: https://api.lightningpayroll.com.au
Default AU production host: https://api.lightningpayroll.com.au
Use the AU or NZ hostname that matches the customer-facing deployment you are integrating with.

Getting API Access

To access the Lightning Payroll API, your company must first be granted access by our development team. This step ensures only approved clients can generate credentials for secure integration.

If you don’t yet have access, please contact our team here to get started.

Once access has been granted, you'll be able to visit the API Management section of your admin dashboard to create a Client ID and Client Secret. These are required to begin the OAuth 2.0 Authorisation Code flow described above.

API Management Page

Authorization Code Flow

1
Redirect the customer to our authorization endpoint.
Replace {base} with the environment you’re targeting.
GET {base}/api/oauth/authorize?
  client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyourapp.com%2Foauth
  &state=xyz123
  &scope=openid%20payroll.write
2
Receive the one-time code.
We redirect back to redirect_uri with ?code=…&state=…. The code is single-use and valid for 10 minutes.
3
Exchange the code for tokens.
POST {base}/api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=THE_CODE_FROM_STEP_2&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
redirect_uri=https%3A%2F%2Fyourapp.com%2Foauth
Successful response:
{
  "access_token":  "…",
  "token_type":    "Bearer",
  "expires_in":    1800,
  "refresh_token": "…",
  "refresh_expires_in": 2592000
}
4
Call protected endpoints.
Include Authorization: Bearer <access_token>. When it expires, swap the refresh_token for a fresh pair:
POST {base}/api/oauth/token
grant_type=refresh_token&
refresh_token=YOUR_REFRESH_TOKEN&
client_id=YOUR_CLIENT_ID&
client_secret=YOUR_CLIENT_SECRET&
redirect_uri=https%3A%2F%2Fyourapp.com%2Foauth

Token Lifetimes

CredentialValid forNotes
Authorization code10 minutesSingle use. Exchanging it a second time fails.
access_token30 minutes (expires_in: 1800)Send as Authorization: Bearer.
refresh_token30 days (refresh_expires_in: 2592000)Rotated on every use. See below.
Refresh tokens rotate, so store the new one. Every successful grant_type=refresh_token exchange revokes the refresh token you just sent and returns a brand new one. If you keep re-sending the original, the second attempt fails and the customer has to authorize again. Persist the refresh_token from every token response, overwriting the previous value, and serialise refreshes so two workers cannot race and revoke each other's token.

Authentication Endpoint Reference

PathDescription
GET /api/oauth/authorizeStarts the Authorization Code flow (302 redirect to sign-in / consent screen).
POST /api/oauth/tokenExchanges an authorization code or refresh_token for fresh tokens.
GET /api/company (example protected)Lists companies the authenticated customer can access.
GET /api/employees/{employee_id}/super-fundsAU only. Employee super-fund endpoints are not used for NZ payroll flows.
POST /api/single-touch/{company_id}/submit-stp-paysAU only. STP endpoints remain available on the public API but should not be used for NZ filing.

Scopes

Request scopes as a space-separated list in the scope query parameter of the authorize step. Ask only for what you need: the scope set is fixed at authorization time, so widening it later means sending the customer back through consent.

ScopeGrants
openidIdentity. Request this in every flow.
payroll.readRead-only access to payroll data. Sufficient for every GET on a company you have access to.
payroll.writeCreate and modify payroll artefacts. Required for every non-GET payroll call, and also satisfies reads.
partner.checkout.previewReseller partners only. Priced previews, meaning dry_run: true order and upgrade requests, plus the read-only discovery endpoints.
partner.checkout.writeReseller partners only. Placing real orders, upgrades and trials (dry_run: false). Required for the Partner Free Trials flow described below.
partner.checkout.cancelReseller partners only. Cancelling a partner-checkout order or trial.
openapiAccepted for backward compatibility. Interactive dashboard sessions carry it by default, but no endpoint requires it, so there is no reason to request it.
mcp.readReserved for the Model Context Protocol surface at /mcp. A token presented there must carry only this scope, so it cannot be combined with the payroll or partner scopes. Not for normal REST integrations.
Scope errors are explicit. A payroll call without a sufficient scope fails on the company access check. A partner-checkout call without the right scope returns 403 with {"detail": "Missing required scope: <scope>"}, naming exactly what was missing, so read the detail string rather than guessing.

Partner Free Trials

If you are a reseller partner, you can start a client on a free one-month trial without placing a billed order. A trial creates no order, so it never reaches an invoice and is never renewed. When the trial ends you place the real order against the same client. Trial emails carry your own branding where you have white-label branding configured.

1
Start the trial.
Send the client and company details. Only the company identifier decides the country: abn for Australia, ird_number for New Zealand. A billing address is optional, because nothing is billed.
POST {base}/api/partner-checkout/trials
Authorization: Bearer YOUR_ACCESS_TOKEN
Idempotency-Key: 6f1c9a2e-trial-0001

{
  "customer": {
    "first_name": "Alice",
    "last_name": "Nguyen",
    "email": "alice@example.com",
    "phone": "+61 7 3000 0000"
  },
  "company": {
    "legal_name": "Sunrise Hospitality Pty Ltd",
    "abn": "10000000000"
  }
}
Keep the returned customer_id and subscription_trial_id. The client is emailed a link to set their password and begin.
2
Track the trial.
Poll for the trials you created and watch days_remaining. Filter with ?status=active, expired, or cancelled.
GET {base}/api/partner-checkout/trials?status=active
Each row also reports converted and converted_order_id, so you can tell which clients you have already ordered for.
3
Convert to a paid subscription.
Place a normal order and add end_customer_id. No new client is created, the billing address is updated in place, and the running trial subscription is retired so the paid plan's limits take effect.
POST {base}/api/partner-checkout/orders
Idempotency-Key: 6f1c9a2e-order-0001

{
  "dry_run": false,
  "end_customer_id": 192601,
  "customer": { "email": "alice@example.com", ... },
  "company":  { "legal_name": "Sunrise Hospitality Pty Ltd",
                "abn": "10000000000" },
  "billing_address": { ... },
  "order": { "product_id": 243 }
}
customer.email must match the client named by end_customer_id, so an order can never be attached to the wrong account.
The free month is granted once per client. order.add_free_trial_month defaults to true and adds an extra month, so a monthly signup gets 2 months and an annual signup gets 13. A client who has already had a free trial does not get it a second time. The order still succeeds: free_trial_month_applied comes back false and warnings explains why. Read that field rather than assuming, so you never quote a client 13 months and deliver 12.
Cancel a trial with POST /api/partner-checkout/trials/cancel if it was provisioned against the wrong entity. Access ends immediately and the records are kept for audit. Full details, including every field and error code, are in the Partner Checkout Admin Endpoints Guide.

Using Your Access Token

Once you have received an access_token, you can begin calling protected endpoints by including it in the Authorization header of your HTTP requests, using the Bearer scheme:

    Authorization: Bearer YOUR_ACCESS_TOKEN
    

This is required for all endpoints that need authentication. Make sure to replace YOUR_ACCESS_TOKEN with the actual token string you received in Step 3 above.

Tip: You can test authenticated API calls directly on this documentation page. Click the “Authorize” button at the top right of the endpoint list and enter your access token in the HTTPBearer area of the popup. Once authorised, the Swagger UI will automatically include your Bearer token in requests while you explore.

Swagger Authorize Button

Swagger Authorize Token Entry

Security tip: Keep client_secret and refresh_token server-side only. Never expose them in a browser or mobile client.

Webhooks

Rather than polling us for changes, register a URL and we will POST to it when a company, an employee or a completed pay is created, updated or deleted. Every delivery is signed, retried on failure, and recorded in a delivery log you can inspect. Webhook management requires an API admin account; a token whose customer is not an API admin gets 403 on every webhook endpoint.

Event Catalogue

Nine event types across three entities. Name each event you want; there is no wildcard. The authoritative list is returned as availableEvents by GET /api/webhooks, so read it at runtime if you want to pick up additions automatically.

EntityCreatedUpdatedDeleted
Companycompany.createdcompany.updatedcompany.deleted
Employeeemployee.createdemployee.updatedemployee.deleted
Paypay.createdpay.updatedpay.deleted

Registering an Endpoint

POST {base}/api/webhooks
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json

{
  "url": "https://yourapp.example.com/hooks/lightning-payroll",
  "events": ["employee.created", "employee.updated", "pay.created", "pay.updated"],
  "description": "Production employee sync",
  "is_active": true
}

Your url must be an absolute http or https URL whose host resolves to a publicly routable address. Use https, because the payload carries payroll data. localhost, .local, .internal, loopback, private RFC1918 ranges and cloud-metadata addresses are rejected with 422 at registration, and the same check runs again on every send, so a host that later becomes unroutable simply fails that attempt and the reason appears in the delivery log. That means you cannot point a registered webhook at your own machine: use a public hostname or a tunnel.

The URL is lightly normalised before storage: surrounding whitespace is trimmed and < and > are stripped. It may be at most 512 characters, and anything longer is rejected with a 422 rather than truncated. description is your own routing note, never sent to your listener; it is trimmed the same way and may be at most 255 characters, likewise rejected rather than truncated.

The signing secret is shown once. Omit secret and we generate a strong one. The plaintext value appears only in the create response, and again only if you later rotate it with {"regenerate_secret": true}. No endpoint returns an existing secret, so store it immediately. Rotation is not zero downtime: the new value does not exist until the request returns and is live for queued deliveries the moment it does, and secret is not an accepted key on PATCH. Either accept a brief window of failures and let the retry schedule cover you, or register a second endpoint with your own secret and retire the first.

Verifying a Delivery

Each delivery arrives as a POST with Content-Type: application/json and four X-LP-* headers.

HeaderMeaning
X-LP-EventThe event type, so you can route before parsing.
X-LP-Delivery-IdThe delivery's own id, stable across every retry of that delivery. Your idempotency key, best used as the pair (X-LP-Delivery-Id, payload id).
X-LP-TimestampUnix epoch seconds as a decimal string, taken when this attempt was signed. Part of the signed material.
X-LP-SignatureHMAC-SHA256 hex digest of "{timestamp}.{raw_body}" keyed with your endpoint secret.
# Python
signed   = timestamp.encode() + b"." + raw_body   # raw_body exactly as received
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-LP-Signature"]):
    abort(401)
Sign the raw bytes, never a re-serialised copy. Our body is compact JSON with no space after : or , and non-ASCII escaped. Parsing the JSON and re-encoding it produces different bytes and therefore a different digest, which fails in a way that looks exactly like a wrong secret. This is the single most common integration problem. In Express, capture the raw body before express.json() touches it.

Payload Shape

{
  "id": "9f2b1c84-6d1e-4a7f-9c0b-2e5d7a3f8b41",
  "type": "employee.updated",
  "createdAt": "2026-08-07T04:11:07.482913",
  "customerId": 192601,
  "entity": { "type": "employee", "id": 4412 },
  "data": { ... }
}

customerId is the end customer whose data changed, not your own partner account, so it is what you route on to attribute an event to the right client. data is built from the same model the REST API uses for that entity, but through a different serialiser, so parse it permissively: jurisdiction-specific fields are not pruned, and a record that fails strict validation falls back to a raw column dump.

Deduplicate on the pair (X-LP-Delivery-Id, payload id). The delivery id is stable across every retry, and a delivery we have already attempted is never re-armed with different data. A delivery that is still queued and untried can be replaced in place if the entity changes again, so in rare cases one delivery id carries a newer payload. Keying on the pair is safe in both cases: skip work only when both values match something you have already processed.

Delivery Statuses and Retries

Any 2xx response counts as delivered. Redirects are not followed, so a 301 or 302 is a failure. We wait up to 10 seconds, so acknowledge with a 2xx first and do your real work asynchronously.

pending sending delivered retry failed skipped

A failed delivery is retried with doubling backoff for a maximum of 8 attempts: 30s, 1m, 2m, 4m, 8m, 16m, then 32m between attempts. From the first attempt to the last is a little over one hour, after which the delivery is failed and never retried. Treat those times as lower bounds, since due deliveries are picked up on a short recurring sweep. skipped means the endpoint was deleted or deactivated between queueing and sending.

A delivery left in sending because a worker restarted mid-attempt is reclaimed automatically and resumes its remaining attempts, so every delivery settles as delivered or failed rather than stalling.

Read the full Webhooks Guide → Complete field tables, verification snippets for Node, Python and PHP, the signature anatomy, the retry ladder drawn to scale, a listener checklist, and a troubleshooting matrix.

API Conventions

Cross-cutting behaviour that applies across the whole surface. Worth reading once before you write a client.

Rate Limits

Most endpoints share a general limit. Pay endpoints are much tighter and cheap lookups are looser. Limits are enforced per client.

LimitApplies to
25 / minuteThe default for most endpoints, including all partner-checkout reads and writes.
120 / minuteCheap lookups such as the allowance list endpoints and address autocomplete.
5 / minuteThe pay endpoints, reads as well as writes: listing pay runs, listing pays, fetching a single pay, creating and updating pays, and the workers-comp pay endpoints. Because the list and fetch endpoints are capped here too, do not poll them: request a date range in one call instead of looping per pay.
5 / minutePayslip PDF rendering.

Exceeding a limit returns 429 with {"error": "Rate limit exceeded: ..."}. A separate burst guard also returns 429 with {"detail": "Too Many Requests"} and a Retry-After header if you send a sharp spike from one IP. Under heavy load you may also see 503 with Retry-After: 5, which is a transient overload signal rather than a rate limit: retry with backoff.

Idempotency

The partner-checkout write endpoints take an Idempotency-Key header so a network failure cannot double-charge a client. It is required on POST /api/partner-checkout/trials, and on POST /api/partner-checkout/orders and /upgrades whenever dry_run is false.

You sendYou get
A new keyThe request is processed normally.
The same key with the same payload200 with the original stored response, plus idempotency_replayed: true. Safe to retry as often as you like.
The same key with a different payload409. Keys are not reusable across different requests.
No key when one is required400.
A key longer than 255 characters400.

Keys are scoped to your partner account, so they can never collide with another partner's, but they are shared across orders, upgrades and trials: reusing one key for a different endpoint still counts as a payload mismatch. Generate a fresh UUID per logical operation and keep it for the life of your retries. Do not rely on a key expiring, and do not rely on a key still replaying once your retry window has passed.

Error Shapes

Three different response shapes exist, and a client that only parses one will silently read undefined from the others.

ShapeWhen
{"detail": "..."}The common case for 400, 401, 403, 404, 409 and most 422s raised by endpoint logic. Read detail first.
{"status_code": 10422, "message": ..., "data": ...}Whenever request validation fails, meaning the body or the query string, path or headers. HTTP status is still 422 and there is no detail key, so an out-of-range limit or an invalid status filter lands here too. The status_code field is 10000 plus the HTTP status, not the HTTP status itself.
{"error": "..."}Rate limits, and the branding and asset upload endpoints. Also the shape of the generic 500.

Treat the message and detail strings as human-readable diagnostics rather than stable machine identifiers: branch on the HTTP status, and log the string.

Dates, Money and Lists

Companies and Access

Payroll endpoints identify the company by path parameter, as /api/company/{company_id}/.... There is no header-based company selector.