Webhooks & events
Webhooks
Signed, retried and replayable event delivery to your HTTPS endpoint, compatible with Zapier, Make and n8n.
Light AI sends an HTTP POST to your endpoint for each event you subscribe to (27 types, see the event catalog). Endpoints are created in Settings → Developers → Webhooks, or through the API with the opt-in webhooks:manage scope.
Subscribe#
curl -X POST "https://api.light-ai.app/v1/webhook_endpoints" \
-H "Authorization: Bearer $LIGHT_AI_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/light-ai",
"events": [
"lead.created",
"quote.approved"
],
"description": "CRM sync"
}'const response = await fetch('https://api.light-ai.app/v1/webhook_endpoints', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
'Idempotency-Key': crypto.randomUUID(),
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "https://hooks.example.com/light-ai",
"events": [
"lead.created",
"quote.approved"
],
"description": "CRM sync"
}),
});
const data = await response.json();import os, uuid, requests
response = requests.post(
"https://api.light-ai.app/v1/webhook_endpoints",
headers={
"Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
"Idempotency-Key": str(uuid.uuid4()),
},
json={
"url": "https://hooks.example.com/light-ai",
"events": [
"lead.created",
"quote.approved"
],
"description": "CRM sync"
},
)
data = response.json()url: HTTPS on port 443 or 8443, a public host. Light AI never follows redirects.events: the types to receive, or["*"]for every type your scopes can read.secret(whsec_…) is returned once. Subscribing the same URL and events again returns the same endpoint with a fresh secret; the old one keeps working for 24 hours (REST-hook friendly).static_header: an optional fixed header (for n8n Header Auth), stored encrypted.exclude_own_origin: skip events caused by this key's own writes. Defaultfalse.
The request#
| Header | Value |
|---|---|
webhook-id | The event id (evt_…), the same on every retry and replay. De-duplicate on it. |
webhook-timestamp | Unix seconds when this attempt was signed. |
webhook-signature | Space-separated v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}. See Verify signatures. |
Light-AI-Event-Type | The event type, e.g. quote.approved. |
Light-AI-Delivery | The delivery id (whd_…), for the delivery log. |
Light-AI-Organization | The contractor organization (org_…). |
The envelope#
data.object is the object as it is when the delivery is sent (rendered at delivery time), data.previous_attributes holds changed fields for updates, and origin says who caused the event: app, embed (website form), portal (the homeowner), api, inbound or system. Partner deliveries carry grant with your external_reference.
{
"id": "evt_3f9c2a7be41d…",
"object": "event",
"api_version": "2026-11-01",
"created": "2026-10-12T14:03:11.000Z",
"livemode": false,
"organization": {
"id": "org_3f9c2a7be41d…",
"name": "Dana Reyes"
},
"grant": {
"id": "grant_3f9c2a7be41d…",
"external_reference": "string",
"metadata": {}
},
"origin": {
"type": "app",
"principal_id": "string",
"source_system": "string",
"external_ref": {
"system": "string",
"namespace": "string",
"id": "string"
}
},
"type": "lead.created",
"data": {
"object": {
"id": "lead_3f9c2a7be41d…",
"object": "lead",
"livemode": false,
"status": "new",
"held": true,
"needs_address": true,
"source": "website_widget",
"source_detail": "string",
"contact": {
"first_name": "Dana",
"last_name": "Reyes",
"email": "dana@example.com",
"email_raw": "string",
"phone": "+15125550134",
"phone_raw": "string",
"phone_verified": true
},
"address": {
"line1": "4102 Oak Hollow Dr",
"line2": "string",
"city": "Austin",
"region": "TX",
"postal_code": "78731",
"country": "US",
"formatted": "string",
"lat": 0,
"lng": 0
},
"service_interest": "christmas_lights",
"service_interest_raw": "string",
"message": "string",
"attribution": {},
"consent": {},
"custom_fields": {},
"notes": "string",
"status_reason": "string",
"client_id": "cli_3f9c2a7be41d…",
"property_id": "prop_3f9c2a7be41d…",
"possible_client_match": {
"client_id": "cli_3f9c2a7be41d…"
},
"project": {
"id": "prj_3f9c2a7be41d…",
"status": "intake",
"pipeline_stage": "request",
"app_url": "https://example.com/…"
},
"form": {
"id": "form_3f9c2a7be41d…",
"name": "Dana Reyes"
},
"mockup_status": "not_requested",
"form_answers": [
{
"key": "surfaces",
"question": "What should we light?",
"group": "lighting",
"type": "multi_choice",
"value": [
"eaves",
"garage"
],
"labels": [
"Roof peaks + gutters",
"Garage doors"
]
}
],
"preview_looks": [
"warm_white"
],
"offer": {
"template": "black_friday",
"headline": "Black Friday: 20% off installs",
"discount_type": "percent",
"discount_percent": 20,
"discount_amount": null,
"label": "20% off",
"ends_at": "2026-11-30T05:59:00Z",
"book_by": "Book by Dec 1 for install before Christmas",
"captured_at": "2026-11-27T14:03:11Z"
},
"assets": {
"photos": [
{
"id": "string",
"url": "https://example.com/…",
"expires_at": "2026-10-12T14:03:11.000Z"
}
],
"mockups": [
{
"id": "string",
"url": "https://example.com/…",
"expires_at": "2026-10-12T14:03:11.000Z",
"look": "warm_white"
}
]
},
"external_refs": [
{
"system": "string",
"namespace": "string",
"id": "string",
"owner": "self",
"read_only": true
}
],
"touch_count": 1,
"last_touched_at": "2026-10-12T14:03:11.000Z",
"submitted_at": "2026-10-12T14:03:11.000Z",
"created_at": "2026-10-12T14:03:11.000Z",
"updated_at": "2026-10-12T14:03:11.000Z",
"replayed": true,
"fixture": true
},
"previous_attributes": {}
}
}Respond fast#
Answer any 2xx within 10 seconds, then process asynchronously. A 410 Gone disables the endpoint at once. Anything else (or a timeout) is retried.
Delivery semantics#
- At least once. The same event can arrive twice (a retry after a timeout, a replay), always with the same
webhook-id. - Not ordered. Compare
data.object.updated_at(anddata.stage_change.sequenceforproject.stage_changed) with what you stored, skip older ones, and upsert bydata.object.id. - Large quotes (over 256 KB) arrive with
truncated: trueand emptyline_items: read the full quote withGET /v1/quotes/{id}.
Retries#
A failed attempt is retried 10 times: after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours, then 24 hours three times, each ±20% jitter (about 4 days in all). Then the delivery is abandoned.
An endpoint with no success for 72 hours and at least 20 failures is disabled. The owners get an e-mail, and endpoints that subscribe to webhook_endpoint.disabled get that event.
Logs, retry and replay#
- Deliveries are logged for 30 days:
GET /v1/webhook_endpoints/{id}/deliveriesand Settings → Developers. POST /v1/webhook_deliveries/{id}/retrysends one delivery again now.POST /v1/webhook_endpoints/{id}/replayre-queues up to 5,000 events from the last 30 days (by default only those not yet delivered, including events missed while the endpoint was disabled). Replays reuse the originalwebhook-id.
curl -X POST "https://api.light-ai.app/v1/webhook_endpoints/we_…/replay" \
-H "Authorization: Bearer $LIGHT_AI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"created_after": "2026-10-10T00:00:00Z",
"types": [
"quote.approved"
]
}'const response = await fetch('https://api.light-ai.app/v1/webhook_endpoints/we_…/replay', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"created_after": "2026-10-10T00:00:00Z",
"types": [
"quote.approved"
]
}),
});
const data = await response.json();import os, requests
response = requests.post(
"https://api.light-ai.app/v1/webhook_endpoints/we_…/replay",
headers={
"Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
},
json={
"created_after": "2026-10-10T00:00:00Z",
"types": [
"quote.approved"
]
},
)
data = response.json()Loops#
When your integration writes to Light AI and also listens to its events, set source_system on your writes (e.g. jobber) and skip events whose origin.source_system is yours, or set exclude_own_origin: true on the endpoint.
Security#
- Always verify the signature; reject timestamps older than 5 minutes.
- Endpoints cannot target private networks or Light AI hosts. Owners are e-mailed for every endpoint created through the API.
- Payloads never contain image URLs or customer quote links unless the endpoint holds the opt-in scopes.