---
title: "Simulate a quote lifecycle step for a test lead"
url: https://docs.light-ai.app/api-reference/test-mode/run-test-helper.md
canonical_url: https://docs.light-ai.app/api-reference/test-mode/run-test-helper
last_updated: 2026-10-13
summary: "POST /v1/test_helpers/leads/{lead_id}/{action}: Test keys only."
api_status: beta
related:
  - https://docs.light-ai.app/api-reference/test-mode
---

# Simulate a quote lifecycle step for a test lead

> POST /v1/test_helpers/leads/{lead_id}/{action}: Test keys only.

`POST /v1/test_helpers/leads/{lead_id}/{action}`

Test keys only. `publish_quote` publishes a test quote for a lead your test key created (optionally from your own quote-website-v1 `document`); `view_quote`, `click_external_link`, `approve_quote` and `decline_quote` then emit the same events a real homeowner would cause, to your test endpoints only.

|   |   |
| --- | --- |
| Credential | API key: `Authorization: Bearer lai_live_…` (or `lai_test_…`). Partner keys add `Light-AI-Organization`. |
| Test mode | Test keys only. |
| Idempotency | `Idempotency-Key` supported. See [Idempotency](https://docs.light-ai.app/idempotency). |

## Path parameters

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `lead_id` | string | yes | A test lead id (`lead_…`). Id prefix `lead_`. |
| `action` | string | yes | The step to simulate. One of: `publish_quote`, `view_quote`, `click_external_link`, `approve_quote`, `decline_quote`. |

## Headers

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `Light-AI-Organization` | string |  | Partner keys: the connected organization to act for (`org_…`). Organization keys may omit it. Id prefix `org_`. |
| `Light-AI-Version` | string |  | Pin a dated API version (defaults to the key's version). One of: `2026-11-01`. |
| `Idempotency-Key` | string |  | Retry-safe writes: the same key and body replay the first response for 24 hours (`Idempotent-Replayed: true`). 8–128 characters. |

## Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `document` | object |  | publish_quote only: a quote-website-v1 document to model the contractor's real line items. |

## Response 200

The test quote after the step.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Id prefix `qt_`. |
| `object` | string | yes | Always `quote`. |
| `livemode` | boolean | yes | false for objects created with a test key. |
| `status` | string | yes | One of: `sent`, `approved`, `declined`, `expired`, `superseded`, `disabled`. |
| `is_current` | boolean | yes |  |
| `superseded_by` | string \| null | yes | Id prefix `qt_`. |
| `replaces_quote_id` | string \| null | yes | Id prefix `qt_`. |
| `external_refs` | object[] | yes | The ids integrations wrote back on this quote (PUT /v1/quotes/{id}/external_refs/{system}). |
| `external_refs[].system` | string | yes |  |
| `external_refs[].namespace` | string \| null | yes |  |
| `external_refs[].id` | string | yes |  |
| `external_refs[].owner` | string | yes | `self`: written by your integration (editable); others are read-only. One of: `self`, `organization`, `partner`. |
| `external_refs[].read_only` | boolean | yes |  |
| `truncated` | boolean |  | Webhook payloads only: present (true) when the quote was too large for one delivery (256 KB). line_items is then empty; read the full quote with GET /v1/quotes/{id}. Never present on API responses. Always `true`. |
| `project` | object | yes |  |
| `project.id` | string | yes | Id prefix `prj_`. |
| `project.pipeline_stage` | string \| null | yes | One of: `request`, `quote_drafted`, `quote_sent`, `approved`, `completed`, `closed_lost`. |
| `project.external_refs` | object[] | yes |  |
| `project.external_refs[].system` | string | yes |  |
| `project.external_refs[].namespace` | string \| null | yes |  |
| `project.external_refs[].id` | string | yes |  |
| `project.external_refs[].owner` | string | yes | `self`: written by your integration (editable); others are read-only. One of: `self`, `organization`, `partner`. |
| `project.external_refs[].read_only` | boolean | yes |  |
| `lead` | object \| null | yes |  |
| `lead.id` | string | yes | Id prefix `lead_`. |
| `lead.source` | string \| null | yes | One of: `website_widget`, `api`, `google_ads_lead_form`, `google_lsa`, `meta_lead_ad`, `highlevel`, `hubspot`, `jobber`, `service_autopilot`, `realgreen`, `sweepandgo`, `deeplawn`, `zapier`, `n8n`, `make`, `crm_import`, `other`, `manual`. |
| `lead.external_refs` | object[] | yes |  |
| `lead.external_refs[].system` | string | yes |  |
| `lead.external_refs[].namespace` | string \| null | yes |  |
| `lead.external_refs[].id` | string | yes |  |
| `lead.external_refs[].owner` | string | yes | `self`: written by your integration (editable); others are read-only. One of: `self`, `organization`, `partner`. |
| `lead.external_refs[].read_only` | boolean | yes |  |
| `client` | object \| null | yes | Contact details need clients:read; otherwise only the id. |
| `client.id` | string | yes | Id prefix `cli_`. |
| `client.first_name` | string \| null |  |  |
| `client.last_name` | string \| null |  |  |
| `client.email` | string \| null |  |  |
| `client.phone` | string \| null |  | E.164, or null when the stored number could not be normalized (see phone_raw). |
| `client.phone_raw` | string \| null |  | The number as stored in Light AI. |
| `client.external_refs` | object[] |  |  |
| `client.external_refs[].system` | string | yes |  |
| `client.external_refs[].namespace` | string \| null | yes |  |
| `client.external_refs[].id` | string | yes |  |
| `client.external_refs[].owner` | string | yes | `self`: written by your integration (editable); others are read-only. One of: `self`, `organization`, `partner`. |
| `client.external_refs[].read_only` | boolean | yes |  |
| `property` | object \| null |  |  |
| `property.id` | string | yes | Id prefix `prop_`. |
| `property.address` | object \| null |  |  |
| `property.address.line1` | string \| null | yes |  |
| `property.address.line2` | string \| null | yes |  |
| `property.address.city` | string \| null | yes |  |
| `property.address.region` | string \| null | yes |  |
| `property.address.postal_code` | string \| null | yes |  |
| `property.address.country` | string \| null | yes |  |
| `property.address.formatted` | string | yes | Always present. Components may be null for addresses entered as one line. |
| `property.address.lat` | number \| null | yes |  |
| `property.address.lng` | number \| null | yes |  |
| `assigned_rep` | object \| null |  |  |
| `assigned_rep.id` | string | yes |  |
| `assigned_rep.name` | string \| null | yes |  |
| `assigned_rep.email` | string \| null | yes |  |
| `title` | string \| null | yes |  |
| `message` | string \| null | yes |  |
| `terms` | string \| null | yes |  |
| `disclaimer` | string \| null |  |  |
| `revision` | object |  |  |
| `revision.id` | string | yes | Id prefix `rev_`. |
| `revision.number` | integer | yes |  |
| `revision.published_at` | timestamp | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `revision.evidence_hash` | string | yes |  |
| `approval_mode` | string \| null | yes | One of: `light-ai`, `external`, `view-only`. |
| `allow_selections` | boolean | yes |  |
| `currency` | string | yes | Always `USD`. |
| `line_items` | object[] | yes |  |
| `line_items[].id` | string | yes |  |
| `line_items[].item_key` | string | yes | The line item id in the published document. |
| `line_items[].code` | string \| null | yes | Stable catalog code (template line item); null for custom lines. |
| `line_items[].name` | string \| null | yes |  |
| `line_items[].description` | string \| null | yes |  |
| `line_items[].measurement` | string \| null | yes |  |
| `line_items[].quantity` | number \| null | yes | The measured quantity (decimal for feet and square feet, whole for counted items); null for an unmeasured line. |
| `line_items[].unit` | string \| null | yes | One of: `ft`, `sqft`, `each`, `set`. |
| `line_items[].unit_price` | Money \| null | yes | Set when quantity × unit_price equals amount to the cent; otherwise null (e.g. mini lights priced by the set) and amount is the line total. |
| `line_items[].unit_price_cents` | integer \| null | yes |  |
| `line_items[].amount` | Money | yes |  |
| `line_items[].total_cents` | integer | yes |  |
| `line_items[].bulb` | string \| null | yes | One of: `c9`, `mini`. |
| `line_items[].colors` | string[] | yes |  |
| `line_items[].pattern` | string \| null | yes |  |
| `line_items[].required` | boolean | yes |  |
| `line_items[].selected` | boolean | yes |  |
| `line_items[].taxable` | null | yes |  |
| `discount` | object \| null |  |  |
| `discount.type` | string | yes | One of: `percentage`, `fixed`. |
| `discount.value` | number | yes |  |
| `discount.label` | string |  | The discount line's name on the customer quote, e.g. "Black Friday offer" (a website request's offer); absent for a plain discount. |
| `totals` | object | yes |  |
| `totals.subtotal` | Money | yes |  |
| `totals.discount` | Money | yes |  |
| `totals.tax` | null | yes |  |
| `totals.total` | Money | yes |  |
| `totals.deposit` | object | yes |  |
| `totals.deposit.percentage` | number | yes |  |
| `totals.deposit.amount` | Money | yes |  |
| `selected_item_ids` | string[] |  |  |
| `links` | object | yes |  |
| `links.customer_url` | string \| null | yes | The homeowner's quote link. Only with the opt-in quotes:share_link scope; every read is audited. |
| `links.app_url` | string \| null | yes |  |
| `links.external_url` | string \| null | yes |  |
| `links.external_label` | string \| null | yes |  |
| `links.pdf` | object \| null | yes | Present only when a PDF is bound to this exact revision. |
| `links.pdf.id` | string | yes |  |
| `links.pdf.sha256` | string | yes |  |
| `links.assets_url` | string \| null |  |  |
| `assets` | object | yes |  |
| `assets.mockups` | object[] | yes |  |
| `assets.mockups[].id` | string | yes |  |
| `assets.mockups[].title` | string \| null | yes | The image's title as published on the quote. For a mockup: the image's own title, else its mockup card's name (cards are named from their light colors, for example "Warm White" or "Candy Cane", unless the contractor renamed them). |
| `assets.mockups[].url` | string \| null | yes | null (ids only) unless the credential or endpoint holds quotes:asset_links: then a durable link that works while the customer link is active (at most 30 days); null for an image no longer stored. |
| `assets.schematics` | object[] | yes |  |
| `assets.schematics[].id` | string | yes |  |
| `assets.schematics[].title` | string \| null | yes | The image's title as published on the quote. For a mockup: the image's own title, else its mockup card's name (cards are named from their light colors, for example "Warm White" or "Candy Cane", unless the contractor renamed them). |
| `assets.schematics[].url` | string \| null | yes | null (ids only) unless the credential or endpoint holds quotes:asset_links: then a durable link that works while the customer link is active (at most 30 days); null for an image no longer stored. |
| `engagement` | object | yes |  |
| `engagement.available` | boolean | yes |  |
| `engagement.first_viewed_at` | timestamp \| null |  | RFC 3339 timestamp (UTC, milliseconds). |
| `engagement.last_viewed_at` | timestamp \| null |  | RFC 3339 timestamp (UTC, milliseconds). |
| `engagement.view_count` | integer |  |  |
| `engagement.active_seconds` | number |  |  |
| `engagement.external_link_clicked` | boolean |  |  |
| `approval` | object \| null | yes |  |
| `approval.source` | string | yes | One of: `light_ai`, `legacy_portal`, `external`. |
| `approval.signed_by` | string \| null |  |  |
| `approval.accepted_at` | timestamp \| null |  | RFC 3339 timestamp (UTC, milliseconds). |
| `approval.selected_item_ids` | string[] \| null |  |  |
| `approval.evidence_hash` | string \| null |  |  |
| `approval.external_ref` | object \| null |  |  |
| `approval.external_ref.system` | string | yes |  |
| `approval.external_ref.namespace` | string \| null |  |  |
| `approval.external_ref.id` | string | yes |  |
| `decline` | object \| null |  |  |
| `decline.source` | string | yes | Always `external`. |
| `decline.reason` | string \| null | yes |  |
| `decline.declined_at` | timestamp \| null | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `decline.external_ref` | object \| null | yes |  |
| `decline.external_ref.system` | string | yes |  |
| `decline.external_ref.namespace` | string \| null |  |  |
| `decline.external_ref.id` | string | yes |  |
| `sent_at` | timestamp \| null | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `approved_at` | timestamp \| null | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `declined_at` | timestamp \| null | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `expires_at` | timestamp \| null | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `created_at` | timestamp | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `updated_at` | timestamp | yes | RFC 3339 timestamp (UTC, milliseconds). |
| `fixture` | boolean |  | Always `true`. |

## Errors

| Status | Meaning |
| --- | --- |
| `400` | Bad request. [`bad_request`](https://docs.light-ai.app/errors#bad_request), [`test_mode_mismatch`](https://docs.light-ai.app/errors#test_mode_mismatch), [`test_mode_unsupported`](https://docs.light-ai.app/errors#test_mode_unsupported), [`unsupported_api_version`](https://docs.light-ai.app/errors#unsupported_api_version) |
| `401` | Missing or invalid credential. [`authentication_required`](https://docs.light-ai.app/errors#authentication_required), [`invalid_api_key`](https://docs.light-ai.app/errors#invalid_api_key), [`api_key_revoked`](https://docs.light-ai.app/errors#api_key_revoked), [`api_key_expired`](https://docs.light-ai.app/errors#api_key_expired), [`api_key_suspended`](https://docs.light-ai.app/errors#api_key_suspended) |
| `403` | Not allowed. [`insufficient_scope`](https://docs.light-ai.app/errors#insufficient_scope), [`plan_upgrade_required`](https://docs.light-ai.app/errors#plan_upgrade_required), [`organization_paused`](https://docs.light-ai.app/errors#organization_paused), [`organization_off_season`](https://docs.light-ai.app/errors#organization_off_season), [`api_disabled`](https://docs.light-ai.app/errors#api_disabled), [`api_access_not_enabled`](https://docs.light-ai.app/errors#api_access_not_enabled), [`ip_not_allowed`](https://docs.light-ai.app/errors#ip_not_allowed), [`forbidden`](https://docs.light-ai.app/errors#forbidden) |
| `404` | Not found (also for objects of other organizations and for organizations without an active grant). |
| `409` | Conflict. [`stale_state`](https://docs.light-ai.app/errors#stale_state), [`conflict`](https://docs.light-ai.app/errors#conflict), [`idempotency_in_progress`](https://docs.light-ai.app/errors#idempotency_in_progress), [`idempotency_scope_mismatch`](https://docs.light-ai.app/errors#idempotency_scope_mismatch), [`quote_already_decided`](https://docs.light-ai.app/errors#quote_already_decided), [`quote_not_current`](https://docs.light-ai.app/errors#quote_not_current), [`billing_review_required`](https://docs.light-ai.app/errors#billing_review_required) |
| `413` | Request bodies are limited to 256 KB. |
| `415` | The body is not JSON: send Content-Type: application/json. [`unsupported_media_type`](https://docs.light-ai.app/errors#unsupported_media_type) |
| `422` | Validation failed. [`idempotency_key_reused`](https://docs.light-ai.app/errors#idempotency_key_reused) |
| `429` | Rate limited or daily cap reached. See Retry-After and the RateLimit headers. [`rate_limited`](https://docs.light-ai.app/errors#rate_limited), [`daily_cap_reached`](https://docs.light-ai.app/errors#daily_cap_reached) |
| `500` | Unexpected server error. Safe to retry with exponential backoff; quote the Request-Id to support. [`internal_error`](https://docs.light-ai.app/errors#internal_error) |
| `503` | Temporarily unavailable. Retry with exponential backoff. [`service_unavailable`](https://docs.light-ai.app/errors#service_unavailable) |

## Examples

**Request**

curl:

```bash
curl -X POST "https://api.light-ai.app/v1/test_helpers/leads/{lead_id}/{action}" \
  -H "Authorization: Bearer $LIGHT_AI_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

JavaScript:

```javascript
const response = await fetch('https://api.light-ai.app/v1/test_helpers/leads/{lead_id}/{action}', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_AI_API_KEY}`,
    'Idempotency-Key': crypto.randomUUID(),
  },
});
const data = await response.json();
```

Python:

```python
import os, uuid, requests

response = requests.post(
    "https://api.light-ai.app/v1/test_helpers/leads/{lead_id}/{action}",
    headers={
        "Authorization": f"Bearer {os.environ['LIGHT_AI_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    timeout=30,
)
data = response.json()
```

**Response 200**

```json
{
  "id": "qt_3f9c2a7be41d…",
  "object": "quote",
  "livemode": false,
  "status": "sent",
  "is_current": true,
  "superseded_by": "qt_3f9c2a7be41d…",
  "replaces_quote_id": "qt_3f9c2a7be41d…",
  "external_refs": [
    {
      "system": "string",
      "namespace": "string",
      "id": "string",
      "owner": "self",
      "read_only": true
    }
  ],
  "truncated": true,
  "project": {
    "id": "prj_3f9c2a7be41d…",
    "pipeline_stage": "request",
    "external_refs": [
      {
        "system": "string",
        "namespace": "string",
        "id": "string",
        "owner": "self",
        "read_only": true
      }
    ]
  },
  "lead": {
    "id": "lead_3f9c2a7be41d…",
    "source": "website_widget",
    "external_refs": [
      {
        "system": "string",
        "namespace": "string",
        "id": "string",
        "owner": "self",
        "read_only": true
      }
    ]
  },
  "client": {
    "id": "cli_3f9c2a7be41d…",
    "first_name": "Dana",
    "last_name": "Reyes",
    "email": "dana@example.com",
    "phone": "+15125550134",
    "phone_raw": "string",
    "external_refs": [
      {
        "system": "string",
        "namespace": "string",
        "id": "string",
        "owner": "self",
        "read_only": true
      }
    ]
  },
  "property": {
    "id": "prop_3f9c2a7be41d…",
    "address": {
      "line1": "4102 Oak Hollow Dr",
      "line2": "string",
      "city": "Austin",
      "region": "TX",
      "postal_code": "78731",
      "country": "US",
      "formatted": "string",
      "lat": 0,
      "lng": 0
    }
  },
  "assigned_rep": {
    "id": "string",
    "name": "Dana Reyes",
    "email": "dana@example.com"
  },
  "title": "string",
  "message": "string",
  "terms": "string",
  "disclaimer": "string",
  "revision": {
    "id": "rev_3f9c2a7be41d…",
    "number": 0,
    "published_at": "2026-10-12T14:03:11.000Z",
    "evidence_hash": "string"
  },
  "approval_mode": "light-ai",
  "allow_selections": true,
  "currency": "USD",
  "line_items": [
    {
      "id": "string",
      "item_key": "string",
      "code": "string",
      "name": "Dana Reyes",
      "description": "string",
      "measurement": "string",
      "quantity": 0,
      "unit": "ft",
      "unit_price": {
        "cents": 245000,
        "decimal": "2450.00",
        "currency": "USD"
      },
      "unit_price_cents": 0,
      "amount": {
        "cents": 245000,
        "decimal": "2450.00",
        "currency": "USD"
      },
      "total_cents": 0,
      "bulb": "c9",
      "colors": [
        "string"
      ],
      "pattern": "string",
      "required": true,
      "selected": true,
      "taxable": null
    }
  ],
  "discount": {
    "type": "percentage",
    "value": 0,
    "label": "string"
  },
  "totals": {
    "subtotal": {
      "cents": 245000,
      "decimal": "2450.00",
      "currency": "USD"
    },
    "discount": {
      "cents": 245000,
      "decimal": "2450.00",
      "currency": "USD"
    },
    "tax": null,
    "total": {
      "cents": 245000,
      "decimal": "2450.00",
      "currency": "USD"
    },
    "deposit": {
      "percentage": 0,
      "amount": {
        "cents": 245000,
        "decimal": "2450.00",
        "currency": "USD"
      }
    }
  },
  "selected_item_ids": [
    "string"
  ],
  "links": {
    "customer_url": "https://example.com/…",
    "app_url": "https://example.com/…",
    "external_url": "https://example.com/…",
    "external_label": "string",
    "pdf": {
      "id": "string",
      "sha256": "string"
    },
    "assets_url": "https://example.com/…"
  },
  "assets": {
    "mockups": [
      {
        "id": "string",
        "title": "string",
        "url": "https://example.com/…"
      }
    ],
    "schematics": [
      {
        "id": "string",
        "title": "string",
        "url": "https://example.com/…"
      }
    ]
  },
  "engagement": {
    "available": true,
    "first_viewed_at": "2026-10-12T14:03:11.000Z",
    "last_viewed_at": "2026-10-12T14:03:11.000Z",
    "view_count": 1,
    "active_seconds": 0,
    "external_link_clicked": true
  },
  "approval": {
    "source": "light_ai",
    "signed_by": "Dana Reyes",
    "accepted_at": "2026-10-12T14:03:11.000Z",
    "selected_item_ids": [
      "string"
    ],
    "evidence_hash": "string",
    "external_ref": {
      "system": "string",
      "namespace": "string",
      "id": "string"
    }
  },
  "decline": {
    "source": "external",
    "reason": "string",
    "declined_at": "2026-10-12T14:03:11.000Z",
    "external_ref": {
      "system": "string",
      "namespace": "string",
      "id": "string"
    }
  },
  "sent_at": "2026-10-12T14:03:11.000Z",
  "approved_at": "2026-10-12T14:03:11.000Z",
  "declined_at": "2026-10-12T14:03:11.000Z",
  "expires_at": "2026-10-12T14:03:11.000Z",
  "created_at": "2026-10-12T14:03:11.000Z",
  "updated_at": "2026-10-12T14:03:11.000Z",
  "fixture": true
}
```
