Know the moment your data changes.
Instead of 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.
01What webhooks give you
A webhook endpoint is a URL you own. When payroll data changes inside one of your clients' accounts, we build a small JSON event describing what changed and POST it to that URL. You choose which of the nine event types you want, so a listener that only cares about new employees never sees pay traffic.
Before you start
- Your Lightning Payroll account must be an API admin account. If it is not, every webhook call returns
403with{"detail": "Forbidden"}. See the API Admin Setup and Management Guide. - You need a bearer token for your own API admin account, not one issued to you for a client. A token minted for an end customer resolves to that customer and gets
403here. An interactive dashboard session works, as does an OAuth access token whose subject is your own API admin account. - Your listener must be reachable from the public internet on a routable address. This is enforced, not advisory. See Registering an endpoint.
Every payload carries a customerId. That is the end customer whose data changed, not your own partner account. Route on customerId to attribute an event to the right client of yours. All of your clients' events arrive at the same endpoint.
You can also manage everything on this page from the API Management section of your Lightning Payroll admin dashboard, on the Webhooks tab, including a browsable delivery history with the exact payload of every attempt. The dashboard and these endpoints act on the same records.
02The event catalogue
Nine event types across three entities. Subscribe by naming each one you want. There is no wildcard, so "events": ["*"] is rejected; list the strings explicitly.
| Entity | Created | Updated | Deleted |
|---|---|---|---|
| Company | company.created | company.updated | company.deleted |
| Employee | employee.created | employee.updated | employee.deleted1 |
| Pay | pay.created2 | pay.updated2 | pay.deleted |
- 1An employee who is soft deleted arrives as
employee.deleted, not as anemployee.updatedthat happens to flip a flag. Subscribe toemployee.deletedif you care about removals. - 2Pay events only fire for a completed pay, and which one you get depends on how it was completed. A pay inserted already complete in a single operation, which is what the API pay-creation endpoints do, arrives as
pay.created. A pay saved as a draft and completed later, which is what the Complete Pay Run action in the app does, arrives aspay.updatedwith no precedingpay.created, because writes to an incomplete pay are suppressed. Subscribe to both if you want to know when a pay is finalised. A draft pay produces nothing at all, so seeing no events while a pay run is still open is the gate working correctly rather than a broken registration.
While a delivery for that entity and event type is still queued and has not been attempted yet, a newer change replaces its payload in place rather than queueing a second delivery, and a change that leaves the data identical is dropped. So a burst of edits generally reaches you as one event carrying the latest state instead of a backlog of near duplicates.
Collapsing stops as soon as we have tried to deliver. Once a delivery has been attempted, whether it succeeded or is waiting on a retry, any further change to that entity creates a brand new delivery, even if the resulting data is identical. Design your listener around current state, not around counting events, and make sure it tolerates being handed the same data twice.
The authoritative list is always returned as availableEvents by GET /api/webhooks, generated from the server's own catalogue. Read it at runtime rather than hardcoding this table if you want to pick up new event types automatically.
03Registering an endpoint
POST /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 }
{
"message": "Webhook created",
"webhook": {
"id": 14,
"url": "https://yourapp.example.com/hooks/lightning-payroll",
"events": ["employee.created", "employee.updated", "pay.created", "pay.updated"],
"isActive": true,
"description": "Production employee sync",
"secret": "xW9c...redacted...q2Zt",
"createdAt": "2026-08-07T04:11:07"
}
}
The create response is the only place you will see this value. No endpoint returns an existing secret, and GET /api/webhooks reports only hasSecret. Store it in your secret manager immediately. If you lose it, the only remedy is rotating it, which invalidates the old one the instant the request returns.
Request fields
| Field | Type | Notes |
|---|---|---|
url | string, required | Absolute http or https URL that resolves to a publicly routable address. Use https: the payload carries payroll data. The value is lightly normalised before storage: surrounding whitespace is trimmed and < and > are stripped. At most 512 characters, and anything longer is rejected rather than truncated. |
events | array of string, required | Non empty. Every entry must appear in availableEvents. |
description | string, optional | Free text for your own routing notes. Never sent to your listener. Surrounding whitespace is trimmed. At most 255 characters, and anything longer is refused with Webhook description must be at most 255 characters rather than truncated. |
is_active | boolean, optional | Defaults to true. An inactive endpoint is never delivered to. |
secret | string, optional | At most 128 characters. Supply your own, or omit it and a strong one is generated for you. |
Which URLs are rejected
We resolve your hostname and check every address it answers with. Anything that is not globally routable is refused with 422. This is checked when you register and again every time we send, so a hostname that later re-points at an internal address stops being delivered to rather than becoming a way into our network.
| Rejected | Examples |
|---|---|
| Internal names | localhost, anything ending .localhost, .local, .internal |
| Loopback | 127.0.0.1, ::1 |
| Private ranges | 10.x, 172.16.x to 172.31.x, 192.168.x |
| Link local and cloud metadata | 169.254.169.254 and the rest of 169.254.0.0/16 |
| Other reserved | Multicast, unspecified and reserved ranges |
| Not a URL | Missing scheme or host, or a scheme other than http / https |
| Unresolvable | A hostname that does not resolve at all is refused with Could not resolve the webhook host |
| Too long | Over 512 characters, refused with Webhook URL must be at most 512 characters. The value is never truncated to fit |
Because of this, you cannot point a registered webhook at localhost while developing. Use a tunnelling service that gives you a public hostname, or run your listener on a host with a real public address.
At registration a rejection is an immediate 422. At send time there is no response to reject, so a URL that has since become unroutable simply fails that attempt: the delivery moves to retry and the reason appears in lastError in the delivery log.
Rotating the secret
Send regenerate_secret on its own. The new value comes back once, as webhook.secret.
PATCH /api/webhooks/14 Authorization: Bearer YOUR_ACCESS_TOKEN { "regenerate_secret": true }
The new secret does not exist until the PATCH returns, and it is already live for every queued delivery the moment it does. You therefore cannot pre-load it into your verifier, and secret is not an accepted key on PATCH: supplying one returns 200 and is silently ignored. So there are two workable approaches.
Accept a brief gap. Rotate, read webhook.secret from the response, and deploy it immediately. Deliveries signed in between fail verification and go into retry, and the retry schedule gives you roughly an hour to get the new secret live before anything is lost.
Or overlap two endpoints. Register a second endpoint with your own secret value, verify traffic is flowing to it, then delete the first. This avoids any gap at the cost of briefly receiving each event twice.
Pausing without losing history
PATCH with {"is_active": false} to stop deliveries and keep the endpoint and its delivery log. DELETE removes the endpoint and its entire delivery history, which cannot be recovered, so export anything you need first.
04What a delivery looks like
Every delivery is a POST with a JSON body and five headers.
| Header | Value |
|---|---|
Content-Type | application/json |
X-LP-Event | The event type, for example employee.updated. Lets you route before parsing. |
X-LP-Delivery-Id | The delivery's own id. Stable across every retry of the same delivery. Use it with the payload id as your deduplication key: see the note below. |
X-LP-Timestamp | Unix epoch seconds as a decimal string, taken when this attempt was signed. Part of the signed material, and safe to compare against your own clock. |
X-LP-Signature | HMAC-SHA256 hex digest. See Verifying the signature. |
{
"id": "9f2b1c84-6d1e-4a7f-9c0b-2e5d7a3f8b41", // event id, changes per payload
"type": "employee.updated",
"createdAt": "2026-08-07T04:11:07.482913", // UTC, no offset or Z
"customerId": 192601, // YOUR CLIENT, not you
"entity": { "type": "employee", "id": 4412 },
"data": {
"id": 4412,
"first_name": "Alice",
"surname": "Nguyen",
// ... the full entity, same shape as the REST API returns
}
}
| Field | Meaning |
|---|---|
id | A UUID identifying this payload. A new one is minted whenever the payload is built or rebuilt. Combined with X-LP-Delivery-Id it is your deduplication key: see the note below. |
type | The event type, matching X-LP-Event. |
createdAt | When the payload was built. ISO 8601 in UTC, with microseconds and no Z or offset suffix. Treat it as UTC when parsing. |
customerId | The end customer whose data changed. Your routing key. |
entity.type | company, employee or pay. |
entity.id | The changed record's id. Note this is not enough on its own to re-fetch the record, because the REST routes are company-scoped: see the re-fetching note below. |
data | The entity, built from the same model the REST API uses for it, but through a different serialiser. Parse it permissively rather than with a strict schema. Two differences are worth knowing: jurisdiction-specific fields are not pruned, so an NZ record can carry AU-only keys and vice versa; and if a record fails strict validation the payload falls back to a raw database-column dump with different key names and a different money representation. |
A retry re-sends the same X-LP-Delivery-Id, which makes it a sound idempotency key. Once we have attempted a delivery we never re-arm it with different data, so a delivery you have already received will not come back carrying something else. 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 arrives with a newer payload.
Key on the pair (X-LP-Delivery-Id, payload id), which is correct in both cases. Skip a delivery only when both match something you have already processed, and treat a familiar delivery id carrying a new payload id as new work. If you can only store one value, store the payload id.
Payroll REST routes are company-scoped as /api/company/{company_id}/..., and the payload does not always give you a company. For employee.* read it from data.company_id. For pay.* the payload carries no company id, so resolve it yourself, for example by caching the pay run to company mapping or by listing companies for that customerId first. Re-fetches use the OAuth access token you hold for that end customer, not your own API admin token.
Consistent with the rest of the API: amounts are JSON numbers rather than strings, and are not zero padded to a fixed number of decimal places, so 0 and 1250.5 are both normal. Timestamps are ISO 8601 UTC with no offset. Never compare money by string equality.
05Verifying the signature
Anyone can POST to your URL. The signature is how you know a delivery is really from us. Verify it on every request and reject anything that does not match.
We concatenate the timestamp, a literal dot, and the exact request body, then HMAC that string with your endpoint's secret using SHA-256, and hex encode the result.
Sign the raw body bytes exactly as received. Do not parse the JSON and re-serialise it. Our body is compact, with no space after : or ,, and non-ASCII characters are escaped. Almost every JSON library re-serialises differently, producing a different byte string and therefore a different digest. This fails in a way that looks exactly like a wrong secret, and it is the reason most first integrations report "the signature never matches". In Express, that means capturing the raw body before express.json() touches it.
const crypto = require('crypto'); const express = require('express'); const app = express(); // Raw body, NOT express.json(). Re-serialising breaks the signature. app.post('/hooks/lightning-payroll', express.raw({ type: 'application/json' }), (req, res) => { const timestamp = req.get('X-LP-Timestamp'); const received = req.get('X-LP-Signature'); const body = req.body; // a Buffer const expected = crypto .createHmac('sha256', process.env.LP_WEBHOOK_SECRET) .update(timestamp + '.') .update(body) .digest('hex'); const a = Buffer.from(expected, 'utf8'); const b = Buffer.from(received || '', 'utf8'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return res.sendStatus(401); } // Reject stale deliveries. The timestamp is signed, so this blocks replays. const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp)); if (ageSeconds > 300) return res.sendStatus(401); const event = JSON.parse(body.toString('utf8')); // Dedup on the PAIR: an untried delivery id can be re-armed with new data. enqueueForProcessing(req.get('X-LP-Delivery-Id'), event.id, event); res.sendStatus(200); // ack fast, work later });
import hashlib, hmac, os, time from flask import Flask, request, abort app = Flask(__name__) SECRET = os.environ["LP_WEBHOOK_SECRET"].encode() @app.route("/hooks/lightning-payroll", methods=["POST"]) def lightning_payroll_hook(): timestamp = request.headers.get("X-LP-Timestamp", "") received = request.headers.get("X-LP-Signature", "") # request.get_data() is the raw body. Do NOT use request.json here. body = request.get_data() signed = timestamp.encode() + b"." + body expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, received): abort(401) # Reject stale deliveries. The timestamp is signed, so this blocks replays. if abs(time.time() - int(timestamp)) > 300: abort(401) event = request.get_json() # Dedup on the PAIR: an untried delivery id can be re-armed with new data. delivery_id = request.headers.get("X-LP-Delivery-Id") enqueue_for_processing(delivery_id, event["id"], event) return "", 200 # ack fast, work later
<?php // php://input is the raw body. Never re-encode the decoded array. $body = file_get_contents('php://input'); $timestamp = $_SERVER['HTTP_X_LP_TIMESTAMP'] ?? ''; $received = $_SERVER['HTTP_X_LP_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $timestamp . '.' . $body, getenv('LP_WEBHOOK_SECRET')); if (!hash_equals($expected, $received)) { http_response_code(401); exit; } // Reject stale deliveries. The timestamp is signed, so this blocks replays. if (abs(time() - (int) $timestamp) > 300) { http_response_code(401); exit; } $event = json_decode($body, true); // Dedup on the PAIR: an untried delivery id can be re-armed with new data. enqueue_for_processing($_SERVER['HTTP_X_LP_DELIVERY_ID'], $event['id'], $event); http_response_code(200); // ack fast, work later
X-LP-Timestamp is Unix epoch seconds and is part of the signed material, so it cannot be altered without breaking the signature. We do not enforce a replay window ourselves, which is why the samples above reject anything older than 300 seconds. Choose a window that comfortably exceeds your own clock drift; 300 seconds is a common choice.
Every attempt is signed with a fresh timestamp, so a legitimate retry is never stale however long the retry ladder runs. Back the window up with the deduplication rule above: record the (X-LP-Delivery-Id, payload id) pairs you have processed and ignore a repeat of a pair you have already handled.
06Delivery lifecycle
Each delivery moves through a small set of states. These are the same status values you see in the dashboard's delivery history and in GET /api/webhooks/deliveries, with the same colours.
Data changes
A company, employee or completed pay is created, updated or deleted in one of your clients' accounts.
Queued
We build the payload and queue one delivery per matching active endpoint.
pendingSent
We POST to your URL and wait up to 10 seconds for a response.
sendingSettled
Any 2xx is success. Anything else is scheduled for another attempt.
delivered retryGiven up
After 8 attempts we stop. The row keeps the last error for you to inspect.
failed| Status | Means |
|---|---|
| pending | Queued and due to be attempted. Not yet tried. |
| sending | An attempt is in flight. Normally this lasts seconds. If our worker restarts mid-attempt a row can sit here longer, in which case it is reclaimed automatically within a few minutes and resumes its remaining attempts, so it still settles as delivered or failed. |
| delivered | You returned a 2xx. Terminal, and the happy path. |
| retry | The attempt failed and another is scheduled at nextAttemptAt. Check lastError and responseCode. |
| failed | All 8 attempts were used. Terminal. We will not try again, and there is no self service replay, so recover by re-fetching the affected records from the REST API. |
| skipped | The endpoint was deleted or deactivated between queueing and sending, so the delivery was dropped rather than attempted. |
What counts as success
- Any status in the 200 to 299 range. Nothing else counts.
- Redirects are not followed. A
301or302is a failure, so register the final URL rather than a redirector. - We wait 10 seconds. A slower response is a timeout and becomes a retry, even if your handler eventually finished the work.
- Your response body is recorded, truncated, so returning a short diagnostic string is genuinely useful when you later inspect a failure.
07Retries and timing
A failed delivery is retried with exponential backoff, doubling each time, for a maximum of 8 attempts. Bars below are drawn to scale, so you can see how quickly the gaps stretch out.
If the eighth attempt also fails, the delivery becomes failed and is never retried. From the first attempt to the last is a little over one hour, which is the window you have to notice and fix an outage before events start being lost.
A delivery that has already been attempted is never re-armed with newer data, so its attempt counter only climbs and the ladder above is the whole story. If the entity changes again mid-ladder, that newer state goes out as its own delivery rather than restarting this one. Read attemptCount from the delivery log rather than inferring it from elapsed time.
Due deliveries are picked up on a short recurring sweep, so the first attempt normally lands within about 30 seconds of the change, and each retry can land slightly after its nominal time. Nothing arrives early. Size your own alerting on the elapsed column rather than expecting to the second.
A useful rule: alert if any of your endpoints has a delivery sitting in retry for more than about 15 minutes. At that point attemptCount reads 5, which still leaves three attempts and roughly 48 minutes to fix the listener before the delivery is lost for good.
08What a good listener does
-
01
Verify the signature before anything else
Reject unsigned or mis-signed requests with a 401 and do no work. Your URL is public, so this is the only thing standing between your database and anyone who guesses the path.
-
02
Return 2xx fast, then do the work
You have a 10 second budget. Persist the event, return 200, and process asynchronously. Doing the real work inline is the most common cause of avoidable retries, and a retry storm on your slowest endpoint will not help.
-
03
Be idempotent on the delivery id and payload id together
A retry re-sends the same
X-LP-Delivery-Id, so skip work only when the payloadidalso matches something you have processed. A familiar delivery id carrying a new payloadidis a newer version of that entity, not a duplicate, and dropping it loses data. -
04
Do not assume ordering
Deliveries are independent and retries reorder things by design, so an
employee.updatedcan arrive before theemployee.createdthat failed twice. Treat each event as "this entity changed, here is its current state" and reconcile, rather than replaying a sequence. -
05
Trust the API over the payload for critical reads
datais a snapshot from when the payload was built. For anything you are about to act on financially, re-fetch the record so you are working from current state. Remember the REST routes are company-scoped, so you need a company id as well asentity.id, and you re-fetch with the token you hold for that end customer. -
06
Tolerate unknown fields and unknown events
New fields can appear inside
data, and new event types can be added to the catalogue. Ignore what you do not recognise instead of failing the request, or a future addition turns into an outage. -
07
Keep each client's OAuth authorization alive
Changes you make through the API are attributed to you by the access token you present, so your own calls always reach your endpoint while that client's authorization is current. Changes the customer makes for themselves, in the web app or the desktop app, carry no partner in context, so they are attributed to that customer's most recently authorized integration. Two consequences worth designing for: if a client's authorization is revoked and never replaced, their changes stop arriving, silently and with nothing in the delivery log, because no delivery is ever created; and where a customer has authorized more than one integration, the changes they make themselves are attributed to one of them rather than to all. Treat the event stream as a fast path rather than a guaranteed-complete ledger, and reconcile against the REST API when you need certainty.
09Endpoint reference
All five endpoints require an API admin bearer token and act only on your own records. They are documented interactively, with a Try it out button, in the Admin section of the API reference.
| Method | Path | Purpose |
|---|---|---|
GET | /api/webhooks | List your endpoints, plus availableEvents. Never returns secrets, only hasSecret. |
POST | /api/webhooks | Register an endpoint. Returns the plaintext secret. |
PATCH | /api/webhooks/{webhook_id} | Partial update. Also where regenerate_secret lives, and the only other response that returns a plaintext secret. |
DELETE | /api/webhooks/{webhook_id} | Delete the endpoint and its whole delivery history. Not recoverable. |
GET | /api/webhooks/deliveries | Delivery log. Filter with webhook_id, status, and limit (1 to 500, default 100). There is no offset, so it always returns the newest rows and cannot be paged past 500. Narrow with the filters instead. |
Errors
| Status | Body | Cause |
|---|---|---|
403 | {"detail": "Forbidden"} | The token's customer is not an API admin. |
404 | {"detail": "Webhook not found"} | No endpoint with that id belongs to you. Another partner's id looks identical to one that does not exist, by design. |
422 | {"detail": "<reason>"} | A rejected field. Reasons include events must be a non-empty list, Unsupported event in events list, Signing secret must be at most 128 characters, and the URL messages in section 03. |
429 | {"error": "Rate limit exceeded: ..."} | You exceeded the request rate. See the rate limit notes on the API documentation home page. |
A 422 raised by the handler itself, meaning a rejected url, an empty or unsupported events list, or an over-long secret, returns {"detail": "<reason>"}.
A 422 raised before the handler runs, meaning a malformed or non-object JSON body, a non-integer {webhook_id}, or a limit outside 1 to 500, returns the framework shape {"status_code": 10422, "message": ..., "data": ...} with no detail key at all. Both are HTTP 422 on these endpoints, so read detail first and fall back to message.
10Troubleshooting
| Symptom | Likely cause and fix |
|---|---|
| The signature never matches | You are almost certainly re-serialising the JSON before hashing. HMAC the raw body bytes as received. Confirm you are signing timestamp + "." + body and not the body alone. If you rotated the secret recently, in-flight deliveries are signed with the new one. |
| Every delivery 401s even though the HMAC is right | Your freshness window is too tight for your own clock drift. X-LP-Timestamp is Unix epoch seconds taken when the attempt was signed, so check your host's clock is in sync and widen the window back to 300 seconds if you narrowed it. |
| Nothing arrives at all | Check isActive on the endpoint, check the event you expect is actually in its events list, then check GET /api/webhooks/deliveries. If there are no delivery rows, no event was ever queued, which points at the event not firing rather than at delivery. |
| No pay events, but employee events work | Pay events only fire for a completed pay, so an open pay run produces nothing. If you completed the run and still saw nothing, check you are subscribed to pay.updated and not only pay.created: a pay saved as a draft and completed later arrives as pay.updated. |
| One client is silent, others are fine | That client's OAuth authorization has probably lapsed or been revoked, so their changes cannot be attributed to you. No delivery row is created in that case, so the log will be empty rather than showing failures. Have them re-authorize. If the silence covers only the changes that client makes directly in the app, rather than the ones you make through the API, check whether they have also authorized another integration: see item 07 in section 08. |
| Fewer events than edits | Working as designed. Repeat changes to the same entity collapse into one delivery carrying the latest state while that delivery is still undelivered. |
| The same data arrives twice | Also expected. There is no content-based deduplication, so a change that produces identical data after an earlier delivery already succeeded is sent again as a new delivery. Deduplicate on (X-LP-Delivery-Id, payload id). |
| Registration rejected with 422 | Your hostname resolves to a non public address, or you are using localhost. Use a public hostname or a tunnel. Read the detail string, which names the exact reason. |
| Deliveries sit in retry with responseCode 301 or 302 | Redirects are not followed. Register the final URL directly. |
| Deliveries time out but your handler works | You are doing the work before responding and exceeding the 10 second budget. Acknowledge with 200 first, process asynchronously. |
| status is skipped | The endpoint was deleted or deactivated after the event was queued. Reactivate it before the change happens, not after. |
| I lost the signing secret | No endpoint returns an existing secret. Rotate with {"regenerate_secret": true} and deploy the new value straight away. Expect in-flight deliveries to fail verification until you do; they will retry. |
You do not need to reproduce a failure to see what we send. Open the Webhooks tab in API Management, pick any delivery, and view its payload. That is the exact body that was signed, so you can replay it against your own handler by hand while you get verification working.