Skip to Content

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

  1. Navigate to Settings → Connect → Webhooks
  2. Click Add Webhook
  3. Enter your endpoint URL
  4. Select the events you want to subscribe to
  5. Click Save

Available Events

EventDescription
customer.createdA new customer was created
customer.updatedA customer record was updated
customer.deletedA customer was deleted
job.createdA new job was scheduled, or a draft job was published
job.completedA job was marked complete
job.cancelledA job was cancelled
job.status_changedA job moved between statuses. Carries oldStatus and newStatus
invoice.createdA new invoice was created
invoice.sentAn invoice was emailed to the customer
invoice.paidAn invoice was paid in full
invoice.overdueAn invoice passed its due date
invoice.voidedAn invoice was voided
invoice.written_offAn invoice was written off as bad debt
invoice.status_changedAny invoice status transition, including ones with no specific event (such as a partial payment). Carries oldStatus and newStatus
estimate.createdA new estimate was created
estimate.sentAn estimate was sent to the customer
estimate.acceptedA customer accepted an estimate
estimate.declinedA customer declined an estimate
estimate.convertedAn estimate was converted to an invoice
estimate.status_changedAny estimate status transition, including ones with no specific event. Carries oldStatus and newStatus
contract.sentA contract was sent for signature
contract.signedA customer signed a contract
payment.receivedA payment was recorded
payment.refundedA 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.createdA purchase order was created
purchase-order.sentA purchase order was sent to the vendor
purchase-order.receivedA purchase order was marked received
purchase-order.cancelledA purchase order was cancelled
time_entry.completedA 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

HeaderDescription
Content-Typeapplication/json
User-AgentLawnLedger-Webhooks/1.0
X-LawnLedger-EventThe event type (e.g. invoice.paid)
X-LawnLedger-SignatureTimestamped HMAC-SHA256 signature — see below
X-LawnLedger-DeliveryStable 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.txt

Rotate the Signing Secret

curl -X POST https://api.lawnledgercrm.com/api/webhooks/clx8n2k1p0005whk/rotate-secret \ -b cookies.txt

Returns 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
Last updated on