Website forms
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. |
| 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 "https://api.light-ai.app/v1/forms?status=active" \
-H "Authorization: Bearer $LIGHT_AI_API_KEY"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();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 -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"
}'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();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_atis required: send theupdated_atyou last read. A form changed since answers 409stale_state; read it again and retry.- You can change
name,status(activeorpaused),domains(replaces the list, at most 25),copyandinstant_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):
- 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). - 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.
- 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.
- 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). - The contractor's own questions (
custom_*, up to 5). - 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. - The owner can drag these question blocks into another order in the editor (a branch's follow-ups move with their parent);
questionsonGET /v1/forms/{id}lists them in that order. The address & photo card, contact, the mockup and thanks stay where they are. - 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/leadsand the other channels still accept a lead with an e-mail or a phone. - 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 "https://api.light-ai.app/v1/leads?form_id=form_…&sort=updated_asc" \
-H "Authorization: Bearer $LIGHT_AI_API_KEY"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();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 urls 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#
{
"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.
// 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:writecan change them (that is whyforms:writeis 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_readyis sent once per lead, afterlead.created, whenmockup_statusleavespending(ready,failedorskipped). The lead stayspendingwhile its one preview is generating: the event fires when it is settled or 30 minutes after the request, so itsassets.mockupsandpreview_lookslist it. It is never sent fornot_requested. It needsleads:readand its origin type isembed.