---
title: "Website request forms"
url: https://docs.light-ai.app/forms.md
canonical_url: https://docs.light-ai.app/forms
last_updated: 2026-10-13
summary: "Read and adjust a contractor's website request forms with /v1/forms, and get each website lead's form, answers (form_answers), photo, instant preview looks and lead.mockup_ready."
api_status: beta
related:
  - https://docs.light-ai.app/api-reference/website-forms
  - https://docs.light-ai.app/webhooks/events
  - https://docs.light-ai.app/inbound
---

# Website request forms

> Read and adjust a contractor's website request forms with /v1/forms, and get each website lead's form, answers (form_answers), photo, instant preview looks and lead.mockup_ready.

A website request form is the estimate form a contractor puts on their own website. The contractor creates and designs it in Light AI (**Website request forms**); the API reads every form, can pause or resume it and adjust its websites, wording and instant preview, and delivers its leads like every other channel.

|   |   |
| --- | --- |
| Plan | Pro and Enterprise (also Pro trials): up to 10 forms on Pro, unlimited on Enterprise. Free, Plus and developer accounts cannot create or edit forms. |
| Endpoints | `/v1/forms` (list) and `/v1/forms/{form_id}` (read, update). See the [reference](https://docs.light-ai.app/api-reference/website-forms). |
| Scopes | `forms:read` to list and read forms; `forms:write` to change one. `forms:write` is an **opt-in**, never part of a preset. |
| Created in | The Light AI app only. Every Pro or Enterprise workspace starts with one ready-made form named **Website form** (the Classic template), created the first time a member who manages forms opens Requests → Forms; it shows in `GET /v1/forms` like any other form. The API cannot create or archive a form. |
| Test keys | `GET /v1/forms` answers an empty list and `PATCH` is refused: forms are live configuration. |

## Forms

curl:

```bash
curl "https://api.light-ai.app/v1/forms?status=active" \
  -H "Authorization: Bearer $LIGHT_AI_API_KEY"
```

JavaScript:

```javascript
const response = await fetch('https://api.light-ai.app/v1/forms?status=active', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
  },
});
const data = await response.json();
```

Python:

```python
import os, requests

response = requests.get(
    "https://api.light-ai.app/v1/forms?status=active",
    headers={
        "Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
    },
)
data = response.json()
```

Each `form_…` carries its status (`active`, `paused`, `archived`), the install snippet (`install.script`, plus `iframe_url` and the optional top-level `hosted_url`), the websites allowed to show it (`domains`, origins such as `https://example.com` or `https://*.example.com`), its fields, photo, consent and questions, its wording (`copy`) and `instant_preview` with this UTC month's use.

### Update a form

curl:

```bash
curl -X PATCH "https://api.light-ai.app/v1/forms/form_…" \
  -H "Authorization: Bearer $LIGHT_AI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "expected_updated_at": "2026-10-14T15:03:11.000Z",
  "status": "paused"
}'
```

JavaScript:

```javascript
const response = await fetch('https://api.light-ai.app/v1/forms/form_…', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "expected_updated_at": "2026-10-14T15:03:11.000Z",
    "status": "paused"
  }),
});
const data = await response.json();
```

Python:

```python
import os, requests

response = requests.patch(
    "https://api.light-ai.app/v1/forms/form_…",
    headers={
        "Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
    },
    json={
        "expected_updated_at": "2026-10-14T15:03:11.000Z",
        "status": "paused"
    },
)
data = response.json()
```

- `expected_updated_at` is required: send the `updated_at` you last read. A form changed since answers 409 [`stale_state`](https://docs.light-ai.app/errors#stale_state); read it again and retry.
- You can change `name`, `status` (`active` or `paused`), `domains` (replaces the list, at most 25), `copy` and `instant_preview`. Questions, fields and design stay in the app.
- Changing the websites or turning previews on e-mails the account owners and appears in Settings → Developers → Activity.

## What the homeowner sees

One question per card, with no step counts. When instant previews are on and available, the form opens with **See your home with lights** (a free AI mockup of the home and a free quote):

1. **What should we light?** (`surfaces`) and **Pick your light color** (`primary_color`, warm white by default; six colors show as bulb swatches, four more under **More colors**).
2. **Address** (with address suggestions when available) and an optional **front photo** (Skip is always offered; a Zillow or Google Maps screenshot works). The AI mockup starts generating in the background as soon as the photo step is done.
3. **Any trees or bushes?** Yes or No. Yes asks how many trees, how to wrap them (only when there is at least one tree), how many bushes and the mini-light color.
4. **Add wreaths or garland?** Yes or No. Yes asks which ones (including **Other decor**), how many, where, and an optional **Anything else about your decor?** text card (`decor_details`).
5. The contractor's own questions (`custom_*`, up to 5).
6. **Anything else we should know?** (`notes`): optional free text, up to 1,000 characters, with Skip. It replaces the contact card's message box on forms that ask it.
7. The owner can drag these question blocks into another order in the editor (a branch's follow-ups move with their parent); `questions` on [`GET /v1/forms/{id}`](https://docs.light-ai.app/api-reference/website-forms/get-form) lists them in that order. The address & photo card, contact, the mockup and thanks stay where they are.
8. **Contact**: first name, e-mail and phone (and the address from step 2) are always collected; last name is required on new forms (the owner can make it optional or hide it). Phone is required on the website form only; `POST /v1/leads` and the other channels still accept a lead with an e-mail or a phone.
9. **Your AI reference mockup**: the request's one AI preview, in the light color the homeowner picked, then a thank-you card.

When previews are off for the form, or unavailable (the Off-Season allowance of 10 requests with AI previews a month used up, low credits, the monthly cap, pause or plan), the form shows no mockup step at all and uses neutral **Get your free lighting estimate** framing; every request is still captured. A form the homeowner leaves before sending the contact card never creates a lead or an event.

### Design and offers

The look of the form is set in the app: a company-wide design (Settings → Request form design) that new forms use, or a custom design per form. Four Light AI campaign templates fill in the copy, look, animation and an offer with dates for the season: **Classic** (the default, Light AI blue with gentle snow beside the form; its offer starts off), **Early Bird**, **Black Friday** and **Last Chance**. The Looks also include **Minimal** (no animation) and every quote link Look under the same name (**Midnight**, **Daylight**, **Evergreen**, **Candy Cane**, **Gilded**, **Frost**, **Workshop**, **Heirloom**); by default forms match your quote link Look. The quote link has **Early Bird**, **Black Friday** and **Last Chance** Looks too, so quotes can match the form. Then the main color, background, heading and body fonts, logo and corners. Animations never cover the form, stop while the page is hidden and are always off for visitors who ask their device for reduced motion. Every design keeps the text at WCAG AA contrast. Forms set up before designs keep the look they had. The **Mockup reveal** sets how the request's AI mockup appears, starting on the homeowner's own photo: **Slider** (default, a draggable before/after divider), **Flicker on**, **Day to night**, **Center bloom**, **Light sweep** or **Night curtain**, with **Slider after reveal** on by default; the form object shows it as `mockup_reveal`. Options with a recurring last day (`availableUntil`, e.g. the timing question's months) grey out once that day has passed on the contractor's calendar, and a submitted pick of one is refused with `option_passed`.

A form can show an optional **offer** banner (a headline, a discount of 0–50% or a whole-dollar amount, an optional end date with a live countdown ticking every second on the server's clock, and a "Book by" note). It hides itself at the end date. Each lead sent while it shows keeps a copy in `offer` (and the project in `request_offer`). The form itself never changes a price: when the request becomes a project, the quote builder pre-fills the quote discount from the offer once, and the contractor can change or remove it. The published quote shows it as `discount` with a `label` such as "Black Friday offer".

## Leads from a form

Every submission becomes a lead with `source: website_widget`, deduplicated like every other channel, and fires `lead.created`. A repeat for the same home or e-mail within the form's duplicate window still arrives, as `lead.touched` on the existing lead; only the instant preview is skipped for it. Read one form's leads with:

curl:

```bash
curl "https://api.light-ai.app/v1/leads?form_id=form_…&sort=updated_asc" \
  -H "Authorization: Bearer $LIGHT_AI_API_KEY"
```

JavaScript:

```javascript
const response = await fetch('https://api.light-ai.app/v1/leads?form_id=form_…&sort=updated_asc', {
  method: 'GET',
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
  },
});
const data = await response.json();
```

Python:

```python
import os, requests

response = requests.get(
    "https://api.light-ai.app/v1/leads?form_id=form_…&sort=updated_asc",
    headers={
        "Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
    },
)
data = response.json()
```

| Lead field | Meaning |
| --- | --- |
| `form` | `{ id, name }` of the form the request came from (`null` for other sources). |
| `custom_fields` | The raw answers by stable key (below). Unchanged from earlier versions. |
| `form_answers` | The same answers in the form's question order, each `{ key, question, group, type, value, labels }`. `value` equals `custom_fields[key]`; `labels` are the display labels (toggles: `["Yes"]` or `["No"]`). Answers to questions the form no longer has come last with `question` set to the key. An empty array for other sources. |
| `mockup_status` | `not_requested` (the form has no preview), `pending`, `ready` (at least one image), `failed` or `skipped` (low credits, a cap, Off-Season, pause or plan). |
| `preview_looks` | The look (light color value) of each preview the homeowner tried, in order. |
| `offer` | The form's offer that was showing when the homeowner sent the request (`headline`, `discount_type` `percent` or `amount`, `discount_percent` 0–50 or `discount_amount` in whole dollars, `label` such as "20% off", `ends_at`, `book_by`, `captured_at`), kept as it was then. `null` when no offer was active, and for other sources. The quote builder pre-fills the quote discount from it once; the contractor can change or remove it. |
| `assets.photos` | The homeowner's photo (`lph_…`). |
| `assets.mockups` | The instant preview images (`mk_…`), each with its `look`. |

GET, list and search answers carry signed HTTPS `url`s valid for 60 minutes. Webhook payloads and the events feed carry only the ids with `url: null`: fetch the lead for fresh links. `form_answers`, `preview_looks`, `look` and `offer` are additive: they appear everywhere a lead is rendered (GET, list, search, every `lead.*` webhook and the events feed).

### Answer keys

Keys and values are stable; contractors can reword questions and labels, so map on keys and values, and show `labels` to people. The server drops answers to disabled questions, unknown keys, invalid values and branch answers whose condition was not met.

| Key | Group | Values |
| --- | --- | --- |
| `surfaces` | `lighting` | Array of `eaves` (roof peaks + gutters), `ridges`, `ground`, `garage`, `columns` (porch pillars), `windows`, `archway`, `other` |
| `primary_color` | `lighting` | `warm_white` (default), `cool_white`, `multi` (classic red, green, blue, orange, yellow), `candy_cane` (red & white), `red_green`, `red_white_green`, `red`, `green`, `blue`, `blue_white`. Forms saved earlier keep their own five. |
| `trees_bushes` | `trees` | `true` or `false` |
| `tree_count` | `trees` | `0`, `1`, `2-3`, `4-6`, `7+` (only when `trees_bushes` is `true`) |
| `tree_wrap` | `trees` | `trunks` or `canopy` (only when `tree_count` is not `0`) |
| `bush_count` | `trees` | `0`, `1`, `2-3`, `4-6`, `7+` (only when `trees_bushes` is `true`) |
| `mini_color` | `trees` | `warm_white`, `cool_white`, `multi`, `red_green` (only when `trees_bushes` is `true`) |
| `decor_wanted` | `decor` | `true` or `false` |
| `decor_items` | `decor` | Array of `wreaths`, `garland`, `bows`, `other` (only when `decor_wanted` is `true`) |
| `decor.<item>.count` | `decor` | One per picked item (`decor.wreaths.count`, `decor.bows.count`): `1`, `2-3`, `4-6`, `7+`; for garland, feet: `under_25`, `25_50`, `50_100`, `100_plus` (only for items picked in `decor_items`) |
| `decor.<item>.where` | `decor` | One per picked item: array of `front_door`, `windows`, `railings`, `columns`, `garage`, `other` (only for items picked in `decor_items`) |
| `<key>.other_text` | the question's | The homeowner's words when they picked **Other** (or **Something else**), up to 200 characters, plain text (for example `surfaces.other_text`, `decor.wreaths.where.other_text`) |
| `primary_color.pattern`, `mini_color.pattern` | `lighting`, `trees` | `alternating` or `double-alternating` (only with a multi-color pick such as `candy_cane`) |
| `install_timing` | `details` | `oct`, `early_nov`, `late_nov`, `dec`, `other`, `asap` |
| `budget_range` | `details` | `under_500`, `500_1000`, `1000_2000`, `2000_3000`, `3000_5000`, `5000_10000`, `10000_plus`, `other` (with `budget_range.other_text`) (optional: absent when the homeowner skips it). Forms saved before it was added get it switched on, before the final notes (a form already at 20 questions does not get it). In the app, a budget of $1,000 or more marks the request **Pre-qualified** by default (each form can change the amount; contractors can also set it on any request); the API does not carry that status yet. |
| `decor_count`, `decor_where` | `decor` | Forms saved before per-item questions (one answer for every item) |
| `decor_details` | `decor` | Free text, up to 500 characters (only when `decor_wanted` is `true`). Plain text: control characters removed, trimmed. |
| `notes` | `details` | Free text, up to 1,000 characters: the final **Anything else we should know?** card. Plain text: control characters removed, trimmed. |
| `custom_…` | `custom` | The contractor's own questions (a generated key such as `custom_k3x9ab`, stable for that question): a choice value, an array, `true`/`false` or text |
| `pattern`, `decorations` | `lighting` | Older forms only (before the current template) |

### Example

**GET /v1/leads/lead_… (a website lead with trees, wreaths and two looks)**

```json
{
  "id": "lead_3f9c2a7be41d…",
  "object": "lead",
  "livemode": true,
  "status": "converted",
  "held": false,
  "needs_address": false,
  "source": "website_widget",
  "source_detail": null,
  "contact": {
    "first_name": "Dana",
    "last_name": "Reyes",
    "email": "dana@example.com",
    "email_raw": null,
    "phone": "+15125550134",
    "phone_raw": "512-555-0134",
    "phone_verified": null
  },
  "address": {
    "line1": "4102 Oak Hollow Dr",
    "line2": null,
    "city": "Austin",
    "region": "TX",
    "postal_code": "78731",
    "country": "US",
    "formatted": "4102 Oak Hollow Dr, Austin, TX 78731, USA",
    "lat": 30.3521,
    "lng": -97.7712
  },
  "service_interest": "christmas_lights",
  "service_interest_raw": null,
  "message": null,
  "attribution": {},
  "consent": null,
  "custom_fields": {
    "surfaces": [
      "eaves",
      "garage"
    ],
    "primary_color": "warm_white",
    "trees_bushes": true,
    "tree_count": "2-3",
    "tree_wrap": "canopy",
    "bush_count": "4-6",
    "mini_color": "multi",
    "decor_wanted": true,
    "decor_items": [
      "wreaths",
      "garland"
    ],
    "decor.wreaths.count": "2-3",
    "decor.garland.count": "25_50",
    "decor.wreaths.where": [
      "front_door"
    ],
    "decor.garland.where": [
      "columns"
    ],
    "custom_k3x9ab": "No inflatables in the front yard"
  },
  "notes": null,
  "status_reason": null,
  "client_id": "cli_8b1e04d2c9a7…",
  "property_id": "prop_51c7e9a03b2d…",
  "possible_client_match": null,
  "project": {
    "id": "prj_c02f6a9d18e4…",
    "status": "intake",
    "pipeline_stage": "request",
    "app_url": "https://light-ai.app/app/projects/…"
  },
  "form": {
    "id": "form_7d2b5e18a6c3…",
    "name": "Website estimate form"
  },
  "mockup_status": "ready",
  "form_answers": [
    {
      "key": "surfaces",
      "question": "What should we light?",
      "group": "lighting",
      "type": "multi_choice",
      "value": [
        "eaves",
        "garage"
      ],
      "labels": [
        "Roof peaks + gutters",
        "Garage doors"
      ]
    },
    {
      "key": "primary_color",
      "question": "Pick your light color",
      "group": "lighting",
      "type": "color",
      "value": "warm_white",
      "labels": [
        "Warm white"
      ]
    },
    {
      "key": "trees_bushes",
      "question": "Any trees or bushes you want lit?",
      "group": "trees",
      "type": "toggle",
      "value": true,
      "labels": [
        "Yes"
      ]
    },
    {
      "key": "tree_count",
      "question": "How many trees?",
      "group": "trees",
      "type": "choice",
      "value": "2-3",
      "labels": [
        "2–3"
      ]
    },
    {
      "key": "tree_wrap",
      "question": "How should we wrap the trees?",
      "group": "trees",
      "type": "choice",
      "value": "canopy",
      "labels": [
        "Full canopy"
      ]
    },
    {
      "key": "bush_count",
      "question": "How many bushes?",
      "group": "trees",
      "type": "choice",
      "value": "4-6",
      "labels": [
        "4–6"
      ]
    },
    {
      "key": "mini_color",
      "question": "Mini light color?",
      "group": "trees",
      "type": "color",
      "value": "multi",
      "labels": [
        "Classic multicolor"
      ]
    },
    {
      "key": "decor_wanted",
      "question": "Add wreaths or garland?",
      "group": "decor",
      "type": "toggle",
      "value": true,
      "labels": [
        "Yes"
      ]
    },
    {
      "key": "decor_items",
      "question": "Which ones?",
      "group": "decor",
      "type": "multi_choice",
      "value": [
        "wreaths",
        "garland"
      ],
      "labels": [
        "Wreaths",
        "Garland"
      ]
    },
    {
      "key": "decor.wreaths.count",
      "question": "How many wreaths?",
      "group": "decor",
      "type": "choice",
      "value": "2-3",
      "labels": [
        "2–3"
      ]
    },
    {
      "key": "decor.garland.count",
      "question": "How many feet of garland?",
      "group": "decor",
      "type": "choice",
      "value": "25_50",
      "labels": [
        "25–50 ft"
      ]
    },
    {
      "key": "decor.wreaths.where",
      "question": "Where should the wreaths go?",
      "group": "decor",
      "type": "multi_choice",
      "value": [
        "front_door"
      ],
      "labels": [
        "Front door"
      ]
    },
    {
      "key": "decor.garland.where",
      "question": "Where should the garland go?",
      "group": "decor",
      "type": "multi_choice",
      "value": [
        "columns"
      ],
      "labels": [
        "Porch columns"
      ]
    },
    {
      "key": "custom_k3x9ab",
      "question": "Any HOA rules we should know about?",
      "group": "custom",
      "type": "text",
      "value": "No inflatables in the front yard",
      "labels": [
        "No inflatables in the front yard"
      ]
    }
  ],
  "preview_looks": [
    "warm_white"
  ],
  "offer": {
    "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": "lph_9e3a7c51d08b…",
        "url": "https://…",
        "expires_at": "2026-10-14T16:03:11.000Z"
      }
    ],
    "mockups": [
      {
        "id": "mk_3f9c2a7be41d…",
        "url": "https://…",
        "expires_at": "2026-10-14T16:03:11.000Z",
        "look": "warm_white"
      }
    ]
  },
  "external_refs": [],
  "touch_count": 0,
  "last_touched_at": null,
  "submitted_at": "2026-10-14T15:01:42.000Z",
  "created_at": "2026-10-14T15:01:42.000Z",
  "updated_at": "2026-10-14T15:12:05.000Z"
}
```

### Mapping answers into a CRM

Write `form_answers` as a readable note, and map fields on `custom_fields` keys (for example `custom_fields.surfaces` to a multi-select, `custom_fields.tree_count` to a number range). Branch keys are simply absent when the question was not shown.

```javascript
// lead: data.object of lead.created / lead.mockup_ready, or GET /v1/leads/{id}
const lines = lead.form_answers.map((answer) => `${answer.question} → ${answer.labels.join(', ')}`);
if (lead.preview_looks.length) lines.push(`AI preview look: ${lead.preview_looks.join(', ')}`);
const wantsTrees = lead.custom_fields.trees_bushes === true;
const surfaces = lead.custom_fields.surfaces ?? []; // e.g. ['eaves', 'garage']
```

## Instant previews and lead.mockup_ready

- On by default for forms the account owner creates in the app (with a 100-credit monthly cap; a form another admin creates starts off). When on, each request gets exactly **one preview image** (in the color the homeowner picked) at **5 credits**, from the contractor's credits, within a required monthly credit cap (5–5,000; turning previews on through the API without a cap sets 250) and a daily limit per organization.
- The first preview starts in the background when the photo step is done, before the contact card, so it is usually ready at the reveal. Previews started on a form the homeowner never sends count against credits and caps (at most that one image) but create no lead and no event.
- In the app only the account owner can turn previews on or raise the spending. Through the API a key or grant with `forms:write` can change them (that is why `forms:write` is an opt-in); the owners are e-mailed when previews are turned on.
- A preview never spends below the Auto Top-Up threshold, never runs while the account is paused, and on Off-Season runs only for the first 10 requests a month (included in the Off-Season price, no credits used): past that the lead is still captured with `mockup_status: skipped`.
- `lead.mockup_ready` is sent once per lead, after `lead.created`, when `mockup_status` leaves `pending` (`ready`, `failed` or `skipped`). The lead stays `pending` while its one preview is generating: the event fires when it is settled or 30 minutes after the request, so its `assets.mockups` and `preview_looks` list it. It is never sent for `not_requested`. It needs `leads:read` and its origin type is `embed`.
