Lightning PayrollDeveloper guides ← Back to API documentation
Webhooks

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.

9 event types HMAC-SHA256 signed up to 8 delivery attempts API admin only

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

Which client does an event belong to?

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.

EntityCreatedUpdatedDeleted
Company company.created company.updated company.deleted
Employee employee.created employee.updated employee.deleted1
Pay pay.created2 pay.updated2 pay.deleted
Repeat changes collapse, but duplicates still happen

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

Request
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
}
Response 200
{
  "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 secret is shown once

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

FieldTypeNotes
urlstring, requiredAbsolute 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.
eventsarray of string, requiredNon empty. Every entry must appear in availableEvents.
descriptionstring, optionalFree 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_activeboolean, optionalDefaults to true. An inactive endpoint is never delivered to.
secretstring, optionalAt 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.

RejectedExamples
Internal nameslocalhost, anything ending .localhost, .local, .internal
Loopback127.0.0.1, ::1
Private ranges10.x, 172.16.x to 172.31.x, 192.168.x
Link local and cloud metadata169.254.169.254 and the rest of 169.254.0.0/16
Other reservedMulticast, unspecified and reserved ranges
Not a URLMissing scheme or host, or a scheme other than http / https
UnresolvableA hostname that does not resolve at all is refused with Could not resolve the webhook host
Too longOver 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.

Rotate
PATCH /api/webhooks/14
Authorization: Bearer YOUR_ACCESS_TOKEN

{ "regenerate_secret": true }
Rotation is not zero downtime

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.

HeaderValue
Content-Typeapplication/json
X-LP-EventThe event type, for example employee.updated. Lets you route before parsing.
X-LP-Delivery-IdThe 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-TimestampUnix 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-SignatureHMAC-SHA256 hex digest. See Verifying the signature.
Body
{
  "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
  }
}
FieldMeaning
idA 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.
typeThe event type, matching X-LP-Event.
createdAtWhen the payload was built. ISO 8601 in UTC, with microseconds and no Z or offset suffix. Treat it as UTC when parsing.
customerIdThe end customer whose data changed. Your routing key.
entity.typecompany, employee or pay.
entity.idThe 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.
dataThe 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.
Deduplicate on the pair

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.

Re-fetching by entity 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.

Money and dates on the wire

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.

X-LP-Timestamp1786190584
.
Raw request body, byte for byte{"id":"9f2b1c84-...","type":"employee.updated",...}
↓  HMAC-SHA256 with your endpoint secret, hex encoded  ↓
X-LP-Signature4f8c1a09e7d2b53c6a91f0e84b7d2c5a3f9e1b8d7c4a6e2f0b9d3c8a5e7f1b4d
The single most common mistake

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.

Express
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
  });
Flask
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
// 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
Replay protection is yours to add

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.

pending

Sent

We POST to your URL and wait up to 10 seconds for a response.

sending

Settled

Any 2xx is success. Anything else is scheduled for another attempt.

delivered retry

Given up

After 8 attempts we stop. The row keeps the last error for you to inspect.

failed
StatusMeans
pendingQueued and due to be attempted. Not yet tried.
sendingAn 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.
deliveredYou returned a 2xx. Terminal, and the happy path.
retryThe attempt failed and another is scheduled at nextAttemptAt. Check lastError and responseCode.
failedAll 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.
skippedThe endpoint was deleted or deactivated between queueing and sending, so the delivery was dropped rather than attempted.

What counts as success

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.

AttemptWait beforeCadenceElapsed
1immediate0s
230s30s
31m1m 30s
42m3m 30s
54m7m 30s
68m15m 30s
716m31m 30s
832m1h 3m 30s

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.

Eight attempts is a real bound

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.

Treat these times as lower bounds

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.

Sizing your monitoring

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

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.

MethodPathPurpose
GET/api/webhooksList your endpoints, plus availableEvents. Never returns secrets, only hasSecret.
POST/api/webhooksRegister 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/deliveriesDelivery 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

StatusBodyCause
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 here comes in two different shapes

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

SymptomLikely 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.
Fastest way to see a real payload

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.