00Before you start
You will need:
- an approved API-admin partner account
- a
client_idandclient_secretcreated for that partner account - at least one registered redirect URI
- a backend service that can safely store the client secret and refresh tokens
If you have not created your OAuth client yet, start with the API Admin Setup and Management Guide first.
This guide does not cover partner branding, connection monitoring, or API-admin setup tasks. For partner branding and white-label configuration, see the API Branding & Co-Branding Guide.
BASE_URL="https://<your-au-sandbox-api-host>" # AU sandbox
# BASE_URL="https://<your-nz-sandbox-api-host>" # NZ sandbox
Set BASE_URL to the AU or NZ host that matches the customer-facing deployment you are testing. Sandbox hostnames are issued with your partner credentials.
01Roles in the flow
There are three separate actors. The OAuth client belongs to the API-admin partner, but the resulting access token represents the customer who completed the hosted login and consent flow.
| Actor | Role |
|---|---|
| API admin partner | Owns the OAuth client and manages branding and connection monitoring. |
| Partner application | Redirects users, exchanges codes, stores refresh tokens, and calls the API. |
| Lightning Payroll customer | Signs in to Lightning Payroll and authorizes access to their payroll account. |
Who can complete consent
By default, only an account administrator can authorize a connection. That grant is scoped to a Lightning Payroll customer account rather than to an individual person or a single company, and the resulting token can read every payroll company in that account.
Lightning Payroll accounts can also have payroll users (sometimes called non-admin users), who sign in with their own credentials and may be restricted to a subset of companies. A payroll user cannot complete the consent step unless your OAuth client has been enabled for per-user grants. Without it, the hosted login screen says so before asking for a password, and /api/oauth/complete independently returns 403 for any payroll-user session, so a caller driving the flow directly gets the same answer.
Per-user grants, where enabled, let each person authorize your integration in their own name. The flow is unchanged; what changes is the token you get back:
| Who consented | admin_mode claim | sub claim | Reach |
|---|---|---|---|
| Account administrator | true | Their email address | The whole account |
| Payroll user | false | Their username | Only what that user may do, including their company restrictions |
The identity survives refresh-token rotation, so a grant made for a payroll user stays that user's grant. If the user is deactivated or removed, the next token exchange fails rather than falling back to administrator access. Ask us if you want this enabled; it is off unless we have discussed it with you. See the Partner Session Handoff Guide for what per-user grants are usually used for.
This matters when the same email address is used twice, which is common for bookkeepers and advisors: an address can belong to an administrator on one account and to a payroll user on a different account. The hosted login screen therefore asks which type of account is signing in before it asks for a password. If your integration deep-links a user into the flow, expect that extra step. The same ambiguity applies when your own integration signs in with a username and password; send account_type=admin to state the identity up front (see "Fetching that bearer token" in the API Admin Setup and Management Guide).
What to tell your customer: unless you are using per-user grants, the person who authorizes the connection must be the account administrator for the payroll account you want access to. Once they have authorized it, the connection covers the whole account, and their payroll users do not need to authorize anything themselves.
02Authentication model & scopes
Supported
- OAuth 2.0 authorization code flow
- Refresh-token rotation
- Bearer access tokens
- PKCE (RFC 7636),
S256only
Not supported
- Client credentials grant
- Password grant
- Open dynamic client registration. An RFC 7591
registration_endpointis advertised, but it only accepts exact matches against a fixed allow-list and refuses to persist anything else. Partner clients are provisioned by Lightning Payroll, not self-registered.
PKCE is optional for confidential clients that authenticate with a client_secret, and required for public clients: a client registered with no stored secret must send code_challenge on /oauth/authorize and the matching code_verifier on /oauth/token, because PKCE takes the place of the secret. plain is rejected; only S256 is accepted.
Supported scopes
| Scope | Purpose |
|---|---|
| openid | Required on /api/oauth/authorize for modern clients |
| openapi | Legacy alternative to openid; still accepted |
| payroll.read | Read-only payroll API access |
| payroll.write | Read/write payroll API access |
| session.handoff | Exchange the user's access token for a single-use browser sign-in link |
| partner.checkout.preview | Partner checkout discovery and preview |
| partner.checkout.write | Partner checkout execution, and renewal management |
| partner.checkout.cancel | Partner checkout cancellation |
Scope recommendations
- Request
openid payroll.readfor read-only payroll integrations. - Request
openid payroll.writefor integrations that create or update payroll data. - Request
openid session.handoffwhen users should click through into Lightning Payroll without signing in again. Add payroll scopes only if your API integration also needs them. - Only request partner-checkout scopes for API-admin partner flows that actually use those endpoints.
- Prefer
openidover the legacyopenapiscope.
How scopes are enforced
For the main payroll API: GET requests require payroll.read or payroll.write; non-GET requests require payroll.write. For partner-checkout endpoints, the server checks the explicit partner scope required by that endpoint.
POST /api/partner/session-handoff requires the exact session.handoff scope as well as per-client enablement. OAuth scopes cannot be added during refresh-token rotation, so users with an older grant must complete /api/oauth/authorize again to approve this scope.
03The flow at a glance
The API admin provisions an OAuth client and registers redirect URIs.
Your app redirects the browser to GET /api/oauth/authorize.
Lightning Payroll redirects the user to its hosted login page.
The customer signs in and approves access.
Lightning Payroll redirects the browser back to your registered redirect_uri with code and state.
Your backend exchanges the code at POST /api/oauth/token.
Your backend stores the returned refresh token securely.
Your backend uses the access token as a bearer token on API calls.
When the access token expires, your backend exchanges the refresh token for a new token pair.
Provisioning + consent in one flow (partner checkout). If you provision customers through partner checkout, you can collapse steps 2-5 into a single emailed flow: pass an oauth_onboarding block to POST /api/partner-checkout/orders and the new customer is emailed a single-use magic link that signs them in and lands them straight on the consent screen, with no separate login or authorize redirect on your side. You still receive code + state at your redirect_uri and exchange them exactly as in steps 3-4. See "OAuth single-flow onboarding" in the Partner Checkout Admin Endpoints Guide.
04Step 1 – Redirect to /api/oauth/authorize
This endpoint is unauthenticated. It validates the client and kicks off the hosted login flow.
curl -i -sS --get "$BASE_URL/api/oauth/authorize" \ --data-urlencode "client_id=your-client-id" \ --data-urlencode "redirect_uri=https://partner.example.com/oauth/callback" \ --data-urlencode "state=partner-state-123" \ --data-urlencode "scope=openid payroll.write"
Expected behaviour
- Returns HTTP
302. - Redirects to Lightning Payroll's hosted login page at
/auth/oauth-login. - Preserves your original
stateinternally and later returns it unchanged to your callback. - Passes through the exact
redirect_uriyou supplied.
Required query parameters
| Parameter | Required | Notes |
|---|---|---|
client_id | Yes | Must be a valid client owned by an API-admin customer |
redirect_uri | Yes | Must exactly match one of the registered redirect URIs |
state | Yes | Opaque value from your app; returned unchanged to your callback |
scope | Yes, in practice | Defaults to openid, but most integrations should request openid payroll.read or openid payroll.write |
Important behaviour
redirect_urimatching is exact-string matching.openidis required on authorize.openapiis still accepted for older clients.- Unsupported scopes return
400. - If the client exists but its owner is not an API admin, the endpoint returns
403. - This step starts in the browser, not as a background server-to-server request.
Common authorize errors
| Status | Response detail | Meaning |
|---|---|---|
| 400 | Invalid client_id | Unknown client |
| 400 | Invalid redirect_uri | Redirect URI is not registered on the client |
| 400 | At least one scope is required. | Scope set was empty |
| 400 | Unsupported OAuth scope(s): ... | One or more scopes are not allowed |
| 400 | The openid scope is required. Legacy 'openapi' is also accepted. | openid/openapi missing |
| 403 | Forbidden: Only API-admin customers can initiate OAuth | Client owner is not API-admin enabled |
| 422 | FastAPI validation error | A required query parameter was missing |
05Step 2 – Hosted login and consent
After /api/oauth/authorize, Lightning Payroll takes over in the browser.
Your app should not call /api/oauth/complete directly. That endpoint is part of the hosted UI flow.
The hosted flow signs the user in, collects consent, creates a one-time authorization code, and redirects back to your redirect_uri.
The hosted login URL is single-use and expires after 10 minutes. If the user sits on an old login page or retries a stale completion URL, restart the flow from /api/oauth/authorize.
In other words: your app starts the flow, Lightning Payroll handles login and consent, and your app takes over again only after the browser returns to your redirect URI.
06Step 3 – Receive the callback
After successful login and approval, the browser returns to your redirect URI:
https://partner.example.com/oauth/callback?code=<authorization_code>&state=<your_original_state>
Authorization-code properties
- Single use
- Valid for 10 minutes
- Tied to the original
client_id - Tied to the original
redirect_uri
Your backend should exchange it immediately and should never reuse it.
07Step 4 – Exchange the code for tokens
Use POST /api/oauth/token with form-encoded data.
curl -sS "$BASE_URL/api/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=<authorization_code>" \ --data-urlencode "client_id=your-client-id" \ --data-urlencode "client_secret=your-client-secret" \ --data-urlencode "redirect_uri=https://partner.example.com/oauth/callback"
Response
{
"access_token": "<jwt-access-token>",
"token_type": "Bearer",
"expires_in": 1800,
"refresh_token": "<opaque-refresh-token>",
"refresh_expires_in": 2592000
}
Token response fields
| Field | Meaning |
|---|---|
access_token | Bearer token for API calls |
token_type | Always Bearer |
expires_in | Access-token lifetime in seconds; currently 1800 |
refresh_token | Opaque refresh token shown once |
refresh_expires_in | Refresh-token lifetime in seconds; currently 2592000 |
Important behaviour
client_secretis sent in the request body, not via HTTP Basic auth.redirect_uriis required for the authorization-code grant.redirect_urimust exactly match the URI used when the code was issued.- The code is marked used as part of a successful exchange.
- This token exchange should happen on your backend, not in browser JavaScript.
08Step 5 – Refresh the token pair
When the access token expires, exchange the refresh token for a fresh access token and a fresh refresh token.
curl -sS "$BASE_URL/api/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=<current_refresh_token>" \ --data-urlencode "client_id=your-client-id" \ --data-urlencode "client_secret=your-client-secret"
Refresh-token rotation rules
- Refresh tokens are single-use.
- The previous refresh token is revoked as soon as it is used successfully.
- If a refresh token is expired, revoked, or unknown, the server returns
400. - Store the replacement refresh token immediately and discard the old one.
09Step 6 – Call the API with the bearer token
ACCESS_TOKEN="<jwt-access-token>" curl -sS "$BASE_URL/api/company" \ -H "Authorization: Bearer $ACCESS_TOKEN"
For write operations, request and use a token that includes payroll.write.
10Rate limits
Limits are counted per API client, not per IP address. Your end customers do not share a bucket with each other, and you do not share one with other partners, so a busy pay cycle for one customer will not throttle the rest.
| Calls | Limit |
|---|---|
/api/oauth/token | 60 per minute |
Pay reads and writes (/api/company/{company_id}/pays*, /api/company/{company_id}/pay-runs, both *-workers-comp-pay* routes) | 60 per minute |
Everything else on /api | 25 per minute, or 120 per minute on list endpoints |
Two things worth building in.
Cache the access token. It is valid for 30 minutes, and expires_in on the token response tells you how long you have. Exchanging a fresh token per API call spends your token-endpoint budget on nothing and is the most common cause of an unexpected 429.
Batch where the endpoint accepts a list. PUT /api/company/{company_id}/pays/create and PUT /api/company/{company_id}/create-workers-comp-pays both take an array of pay objects, so one call covers every employee in a company for a given pay date. A per-employee loop is the same work spread across many more requests.
On 429, back off and retry rather than failing the run. These figures are enforced approximately and may be tuned, so treat them as the working envelope rather than a contractual guarantee, and do not tune your client to sit exactly on the boundary.
11Token-handling guidance
Treat access tokens as bearer tokens
Lightning Payroll access tokens are JWTs, but integrations should treat them as bearer tokens rather than relying on undocumented claims. Today the token includes claims such as sub, admin_mode, api_client_id, scope and exp. Do not build hard dependencies on claim shape beyond what the API contract documents. There is no published JWKS or aud/iss verification contract in this integration surface.
Store refresh tokens server-side only
Refresh tokens should be stored only in your backend or secure server-side secret store. Do not:
- expose refresh tokens to browsers
- log refresh tokens
- persist superseded refresh tokens after rotation
12Full error reference for /api/oauth/token
| Status | Response detail | Meaning |
|---|---|---|
| 200 | token response | Success |
| 400 | Missing code for grant_type=authorization_code | code missing |
| 400 | Missing redirect_uri for grant_type=authorization_code | redirect_uri missing on code exchange |
| 400 | Invalid or expired code | Unknown, expired, used, wrong-client, or wrong-redirect code |
| 400 | Missing refresh_token for grant_type=refresh_token | refresh_token missing |
| 400 | Invalid or expired refresh token | Unknown, expired, or revoked refresh token |
| 401 | Invalid client credentials | Bad client_id / client_secret combination |
| 404 | Customer not found | Token subject no longer exists |
| 422 | FastAPI validation error | Unsupported grant_type or missing required form fields |
13Design notes & production checklist
Design constraints and implementation notes
Worth accounting for in your integration design:
- PKCE is supported and required for public clients; confidential clients should still keep the client secret on a server-side backend, never in browser JavaScript.
- There is no client-credentials grant, so you must use the hosted customer authorization flow.
stateis preserved and returned unchanged. Use it for CSRF protection and request correlation.- Redirect URI matching is strict. Keep environment-specific callback URLs registered exactly as used.
- The same OAuth client can be used by many Lightning Payroll customers, and the API-admin account can inspect and revoke those connections through the API-admin management endpoints.
Recommended production checklist
Register separate redirect URIs for dev, staging, and production.
Request the narrowest scope set you need.
Exchange authorization codes immediately.
Rotate refresh tokens exactly as returned by the token endpoint.
Retry token refreshes carefully; never reuse an old refresh token after a successful refresh.
Monitor customer connections and request errors from the API-admin endpoints.