---
title: "Event catalog"
url: https://docs.light-ai.app/webhooks/events.md
canonical_url: https://docs.light-ai.app/webhooks/events
last_updated: 2026-10-13
summary: "All 27 webhook event types, what triggers them and which scope they need."
api_status: beta
related:
  - https://docs.light-ai.app/webhooks
  - https://docs.light-ai.app/api-reference/events
---

# Event catalog

> All 27 webhook event types, what triggers them and which scope they need.

Every event uses the [envelope](https://docs.light-ai.app/webhooks#the-envelope). An endpoint receives only the types it lists (or `["*"]`) and that its credential's scopes can read. Events are also listed by `GET /v1/events` for 30 days.

## Leads

### `lead.created`

A new lead (any channel), once per lead. needs_address true when it waits for an address. Spam and blocked website repeats are not leads; one that is later reopened as a live lead is announced then (within 30 days of the request).

Needs `leads:read` · `data.object`: Lead

### `lead.touched`

A repeat request folded into an existing lead (API, inbound, or a website request blocked as a repeat of an earlier one): data.touch is the new request.

Needs `leads:read` · `data.object`: Lead

### `lead.updated`

Status, hold or address change (archived, spam, reopened, held → converted, address added → converted).

Needs `leads:read` · `data.object`: Lead

### `lead.mockup_ready`

A website request form lead's instant preview step finished (sent once, after lead.created, never delaying it): data.object.mockup_status is ready, failed or skipped. It fires once the request's one preview is settled (or 30 minutes after the request), so data.object.assets.mockups and preview_looks list it (older leads may list up to 3). Image URLs are not in the payload: GET /v1/leads/{id} returns data.object.assets with 60-minute signed links. Origin type is always `embed`.

Needs `leads:read` · `data.object`: Lead

## Clients

### `client.created`

A new client.

Needs `clients:read` · `data.object`: Client

### `client.updated`

Name, email or phone changed, or a property was added, edited or removed or the primary property changed (previous_attributes.updated_fields: first_name, last_name, email, phone, properties, primary_property), or another client was merged into this one (updated_fields `merged`: its client.deleted names this client in merged_into). Coalesced per client, per origin and per minute, trailing edge: a webhook endpoint gets one delivery per window, sent at its end and showing the client as it is then. A recorded event is never changed: a later edit in the same minute is a new client.updated (updated_fields lists every field the window changed so far), so the events feed can list more than one per minute, and an earlier delivery not yet sent is cancelled (superseded) in favor of the new one.

Needs `clients:read` · `data.object`: Client

### `client.deleted`

A client was deleted: id and external refs only. merged_into is set when the contractor merged it into an existing client (re-point your records to that client).

Needs `clients:read` · `data.object`: ClientTombstone

## Projects

### `project.created`

A new project (from a lead with an address, or created in the app). A website request blocked as a repeat still gets its own project in the app: its lead is that request (status `duplicate`), announced as lead.touched on the earlier lead.

Needs `projects:read` · `data.object`: Project

### `project.stage_changed`

The pipeline stage changed (a move, a quote or approval, a saved measurement in the app, a deleted draft quote). data.stage_change is the transition at event time ({from, to, sequence}); trigger on data.stage_change.to, not on data.object.pipeline_stage (the current stage). previous_attributes.pipeline_stage equals stage_change.from.

Needs `projects:read` · `data.object`: Project

## Quotes

### `quote.published`

A quote revision was published (a new quote id; key CRM records on project.id).

Needs `quotes:read` · `data.object`: Quote

### `quote.viewed`

The homeowner opened the quote (needs the engagement entitlement).

Needs `quotes:read` · `data.object`: Quote

### `quote.external_link_clicked`

The homeowner clicked the quote's external link.

Needs `quotes:read` · `data.object`: Quote

### `quote.change_requested`

The homeowner used "Request a change" on the quote link (the contractor turned on Let customers request changes for this quote): data.change_request is the request ({id, message, created_at}). One event per request; the quote stays sent and can still be approved, and the pipeline stage does not change. A homeowner has one open request per quote link at a time (the next is possible once the contractor marks it resolved, publishes an update or after 24 hours). Live mode only: test mode cannot simulate it. Origin type is `portal`.

Needs `quotes:read` · `data.object`: Quote

### `quote.approved`

Approved by the homeowner, in the app, or recorded with mark_approved. An approval with a different selection of optional items approves a new quote (replaces_quote_id names the published one), announced first by quote.superseded for the published quote (superseded_by this one), live and in test mode alike.

Needs `quotes:read` · `data.object`: Quote

### `quote.declined`

Declined (maps to closed-lost).

Needs `quotes:read` · `data.object`: Quote

### `quote.expired`

The link expired without a decision (maps to closed-lost).

Needs `quotes:read` · `data.object`: Quote

### `quote.link_disabled`

The quote's customer link stopped working. previous_attributes.reason says why: `disabled` (the contractor turned the link off) or `rotated` (the contractor issued a new link: the old one is dead, and data.object.links.customer_url carries the new one when you hold quotes:share_link; re-read the quote rather than marking its link dead). A `disabled` link can be turned back on: quote.link_enabled follows. Rotating a disabled link sends `rotated`, then quote.link_enabled (the new link works).

Needs `quotes:read` · `data.object`: Quote

### `quote.link_enabled`

A disabled customer link works again: the contractor turned the same link back on, or rotated it (a new link, announced first by quote.link_disabled `rotated`; re-read customer_url). data.object.status is current; previous_attributes.status is `disabled`. Resume follow-ups you stopped on quote.link_disabled.

Needs `quotes:read` · `data.object`: Quote

### `quote.superseded`

This quote was replaced (superseded_by names the replacement): the contractor published a newer revision, or the quote was approved with a changed selection of optional items, which approves a new quote (its quote.approved follows). Close this quote in your system and link the replacement.

Needs `quotes:read` · `data.object`: Quote

## Mockups

### `mockup.completed`

A mockup generation finished with at least one image (images[].status says which; failed images were refunded). Image URLs are not in the payload: GET /v1/mockup_generations/{id} for signed URLs. Needs mockups:read.

Needs `mockups:read` · `data.object`: MockupGeneration

### `mockup.failed`

A mockup generation finished without any image; every credit was refunded (failure_code says why). Needs mockups:read.

Needs `mockups:read` · `data.object`: MockupGeneration

## Account and partners

### `organization.entitlement_changed`

Plan, pause, Off-Season, kill-switch or quote-engagement change (delivered even when it ends your access). paused and off_season are what is in effect: a seasonal pause or Off-Season scheduled ahead is announced when it starts (within the hour), not when it is scheduled; while paused the organization is read-only and inbound leads are held. previous_attributes holds the earlier tier, paused, api_disabled, off_season and engagement. engagement turns off while a payment is past due (the tier stays): quote.viewed and quote.external_link_clicked stop until it turns on again. API access is included on Pro: Pro, Enterprise and Pro trials have the full API; Free and Plus have none (tier names the plan; live_api, test_mode and the rest are false). A drop below Pro suspends the organization's keys, sources and endpoints (never deleted) right after this event, and a partner's grant carries none of its data; an upgrade to Pro restores them.

No read scope needed · `data.object`: OrganizationEntitlements

### `grant.created`

A contractor connected your partner account. Sent once per grant status: with data.object.status `pending` when a grant awaits your confirmation (POST /v1/partner/grants/{id}/confirm), and again with `active` once it is confirmed (a grant that starts active gets only that one). Provision on data.object.id with status `active`; the two deliveries have different event ids. A contractor that re-consents (new scopes) gets a new grant: data.object.replaces_grant_id names the grant it supersedes, so re-map that contractor (or de-duplicate on organization_id) rather than provisioning it twice.

No read scope needed · `data.object`: GrantLifecycle

### `grant.revoked`

A contractor's grant ended (revoked or expired); delivered as a farewell.

No read scope needed · `data.object`: GrantLifecycle

### `api_key.expiring`

Sent 14 and 3 days before the key expires, to that key's own endpoints (a partner key: the partner's endpoints) that subscribe to it (list it in events, or use ["*"]). The owners also get an e-mail.

No read scope needed · `data.object`: ApiKeyExpiring

### `webhook_endpoint.disabled`

Another endpoint of the same owner was disabled by Light AI: a key's endpoint notifies that key's other endpoints, a partner's notifies the partner's endpoints, a dashboard endpoint the organization's other dashboard endpoints; only endpoints that subscribe to it (list it in events, or use ["*"]) receive it. The owners also get an e-mail.

No read scope needed · `data.object`: WebhookEndpointDisabledNotice

### `ping`

Test delivery (only to the endpoint it was sent to).

No read scope needed · `data.object`: PingObject

## Change requests

When the contractor turns on **Let customers request changes** for a quote (off by default), the homeowner can send a short **Request a change** message from the quote link. Each request emits `quote.change_requested`: `data.object` is the quote (key your records on `project.id`) and `data.change_request` is `{id, message, created_at}`, with the homeowner's message as they typed it (plain text, up to 500 characters; treat it as untrusted input). The quote stays open and can still be approved, and the project keeps its stage. If the contractor then publishes an updated quote you receive `quote.superseded` and `quote.published` as usual. Endpoints keep the event list they were saved with (`["*"]` is expanded when you save), so add `quote.change_requested` to an existing endpoint to receive it.
