Webhooks
Webhooks let external systems receive notifications when events occur in LawnLedger. When an event fires, LawnLedger sends an HTTP POST to your configured endpoint URL.
Webhooks are a Business plan feature. On lower plans every endpoint in this page returns 403.
Setting Up Webhooks
Via the API
curl -X POST https://api.lawnledgercrm.com/api/webhooks \
-H "Content-Type: application/json" \
-b cookies.txt \
-d '{
"url": "https://your-server.com/webhooks/lawnledger",
"events": ["invoice.paid", "estimate.accepted", "contract.signed"]
}'Event names are validated against the catalog below. An unrecognised name returns 400 — the whole request is rejected, not just the bad entry.
Via the App
- Navigate to Settings → Connect → Webhooks
- Click Add Webhook
- Enter your endpoint URL
- Select the events you want to subscribe to
- Click Save
Available Events
| Event | Description |
|---|---|
customer.created | A new customer was created |
customer.updated | A customer record was updated |
customer.deleted | A customer was deleted |
job.created | A new job was scheduled, or a draft job was published |
job.completed | A job was marked complete |
job.cancelled | A job was cancelled |
job.status_changed | A job moved between statuses. Carries oldStatus and newStatus |
invoice.created | A new invoice was created |
invoice.sent | An invoice was emailed to the customer |
invoice.paid | An invoice was paid in full |
invoice.overdue | An invoice passed its due date |
invoice.voided | An invoice was voided |
invoice.written_off | An invoice was written off as bad debt |
invoice.status_changed | Any invoice status transition, including ones with no specific event (such as a partial payment). Carries oldStatus and newStatus |
estimate.created | A new estimate was created |
estimate.sent | An estimate was sent to the customer |
estimate.accepted | A customer accepted an estimate |
estimate.declined | A customer declined an estimate |
estimate.converted | An estimate was converted to an invoice |
estimate.status_changed | Any estimate status transition, including ones with no specific event. Carries oldStatus and newStatus |
contract.sent | A contract was sent for signature |
contract.signed | A customer signed a contract |
payment.received | A payment was recorded |
payment.refunded | A payment was refunded, from the app or from the Stripe dashboard. data.refundedAmount is always POSITIVE; data.origin is API or STRIPE_DASHBOARD |
purchase-order.created | A purchase order was created |
purchase-order.sent | A purchase order was sent to the vendor |
purchase-order.received | A purchase order was marked received |
purchase-order.cancelled | A purchase order was cancelled |
time_entry.completed | A crew member clocked out. Excludes drive-time entries, which are mileage logs rather than billable work |
A specific event and the generic *.status_changed for the same entity both fire — for example, marking an invoice paid delivers invoice.paid and invoice.status_changed, and voiding one delivers invoice.voided and invoice.status_changed. Subscribe to whichever granularity you need; subscribing to both means two deliveries for the same transition.
*.status_changed covers every transition that has a specific event, plus the ones that don’t — a partial payment, for instance, has no event of its own and reaches you only through invoice.status_changed. So subscribing to it alone gives you the full lifecycle.
Two transitions are currently outside it: an estimate flipping to VIEWED when the customer first opens it, and to EXPIRED when it passes its valid-until date. Both happen in the customer portal and emit no webhook today.
The * wildcard
Subscribing to "*" delivers every event in the table above, and nothing else. Events your own workflow automations define are namespaced workflow.<name> and are deliberately excluded from the wildcard — an endpoint that wants those must name them explicitly.
Webhook Payload
Every request body has the same envelope. data varies by event.
{
"event": "invoice.paid",
"timestamp": "2026-06-15T14:30:00.000Z",
"organizationId": "org_abc123",
"data": {
"invoiceId": "clx8n2k1p0009wxyz",
"invoiceNumber": "INV-0042",
"oldStatus": "SENT",
"newStatus": "PAID",
"amountDue": 0
}
}Request Headers
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | LawnLedger-Webhooks/1.0 |
X-LawnLedger-Event | The event type (e.g. invoice.paid) |
X-LawnLedger-Signature | Timestamped HMAC-SHA256 signature — see below |
X-LawnLedger-Delivery | Stable id for this logical event, constant across retries. Use it to deduplicate |
Verifying Webhook Signatures
The signature header is a comma-separated list:
X-LawnLedger-Signature: t=1750000000,v1=5257a869e7ecebeda32affa62cdca3fa...t is the Unix timestamp (seconds) at send time. Each v1 is the hex HMAC-SHA256 of <t>.<raw request body>, keyed with your webhook secret.
The signed value is the timestamp, a literal ., and the raw request body — not the body alone. Sign the exact bytes you received; re-serializing the parsed JSON changes them and the signature will not match.
There may be more than one v1 value. During a secret rotation, deliveries are signed with both the new and previous secrets for a 24-hour window so you can redeploy without dropping traffic. Treat any match as valid.
const crypto = require("crypto");
function verify(rawBody, header, secret, toleranceSec = 300) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=");
if (key === "t") timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
// Reject replays of an old, validly-signed request.
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return signatures.some(
(sig) =>
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
}Your webhook secret is shown once when the endpoint is created, and again each time you rotate it, in Settings → Connect → Webhooks.
Delivery Behaviour
Respond quickly. Return a 2xx as soon as you have durably accepted the event; do the work afterwards. Deliveries time out after 10 seconds.
At-least-once. A delivery may arrive more than once — for example if your 2xx was lost in transit. Deduplicate on X-LawnLedger-Delivery, which stays constant across all retries of one logical event.
Redirects are not followed. Any 3xx counts as a failed delivery. If your URL redirects (a bare http:// that 301s to https://, or a trailing-slash redirect), register the final URL instead — otherwise nothing is ever delivered.
Ordering is not guaranteed. Use the timestamp field in the payload if order matters.
Retry Policy
A delivery fails if your endpoint returns a non-2xx status, redirects, times out, or is unreachable. LawnLedger then retries with a fixed backoff:
- Attempt 1 — immediately
- Attempt 2 — after 1 minute
- Attempt 3 — after 5 minutes
- Attempt 4 — after 15 minutes
- Attempt 5 — after 1 hour
After the final attempt the delivery is marked dead. You can view dead deliveries and requeue them from the webhooks settings page.
An endpoint that accumulates 10 consecutive failed deliveries is automatically deactivated and stops receiving events until you re-enable it. The counter resets on any successful delivery, or when you requeue a failed one.
Managing Webhooks
List Webhooks
curl https://api.lawnledgercrm.com/api/webhooks \
-b cookies.txtRotate the Signing Secret
curl -X POST https://api.lawnledgercrm.com/api/webhooks/clx8n2k1p0005whk/rotate-secret \
-b cookies.txtReturns the new secret and previousSecretExpiresAt. The previous secret keeps producing a valid second signature until that moment, giving you a 24-hour window to deploy the new one. Rotating again inside the window replaces the previous secret immediately rather than extending it.
Delete a Webhook
curl -X DELETE https://api.lawnledgercrm.com/api/webhooks/clx8n2k1p0005whk \
-b cookies.txt