# Lead forms

URL: /docs/guides/lead-forms

Render a store form from its schema, submit it safely with spam protection and consent, and know what happens to each lead.

Forms are built in the store admin (enquiry, quote request, newsletter sign-up, or blank) and published to your storefront through the API. Your site reads the form's schema, renders it in your own design, and posts the answers back. Each submission lands in the CRM as a contact, and can open a lead, send email notifications and start flows.

## Read the form [#read-the-form]

`GET /v1/forms/{handle}` returns the public schema. It never includes the form's notification settings or internal wiring.

```bash tab="cURL"
curl https://api.elaraone.app/api/v1/forms/quote-request \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET"
```

```ts tab="TypeScript"
const res = await fetch(`${ENGINE}/forms/quote-request`, { headers: engineHeaders() })
if (res.status === 404) notFound() // unknown or switched-off form
const { data: form } = await res.json()
```

```json title="Response (trimmed)"
{
  "data": {
    "handle": "quote-request",
    "name": "Quote request",
    "fields": [
      {
        "handle": "name",
        "type": "text",
        "label": "Your name",
        "required": true,
        "options": [],
        "rules": ["max:120"],
        "constraints": { "max_length": 120 },
        "marketing_consent": false
      },
      {
        "handle": "email",
        "type": "email",
        "label": "Email",
        "required": true,
        "options": [],
        "rules": [],
        "constraints": {},
        "marketing_consent": false
      },
      {
        "handle": "budget",
        "type": "select",
        "label": "Budget",
        "required": false,
        "options": ["Under £500", "£500 to £2,000", "Over £2,000"],
        "rules": [],
        "constraints": {},
        "marketing_consent": false
      },
      {
        "handle": "news",
        "type": "checkbox",
        "label": "Send me offers and news from Hearth & Oak",
        "required": false,
        "options": [],
        "rules": [],
        "constraints": {},
        "marketing_consent": true
      }
    ],
    "captcha": { "provider": "turnstile", "site_key": "0x4AAAAAAA…" }
  }
}
```

A form that does not exist or is switched off answers `404 NOT_FOUND`; the API never says which.

## Render the fields [#render-the-fields]

| `type` | Render as | Sent as |
|---|---|---|
| `text` | `<input type="text">` | string |
| `email` | `<input type="email">` | string, checked as an email address |
| `phone` | `<input type="tel">` | string |
| `textarea` | `<textarea>` | string |
| `select` | `<select>` with `options` | one of `options`, exactly |
| `checkbox` | `<input type="checkbox">`, unticked | `true` or `false` |
| `hidden` | `<input type="hidden">` that your page fills in (a product name, for example) | string |
| `number` | `<input type="number">` | number |
| `date` | `<input type="date">` | a date string such as `2026-11-14` |

File upload questions are coming soon. Until then the API leaves them out of the schema.

Use each field's `handle` as the input name and `label` as its label. For client-side checks:

- `required` on a checkbox means it must be ticked.
- `constraints` maps the server's rules onto HTML attributes: `min_length` and `max_length` on text-like fields, `min` and `max` on numbers. Use them for `minlength`, `maxlength`, `min` and `max`.
- `rules` lists the exact rules the server applies, such as `max:120` or `alpha_dash`, if you want to mirror more of them.

The server checks everything again, so client-side checks are for the shopper's convenience only.

## Spam protection [#spam-protection]

Every form has two protections. Render both.

**Honeypot.** Add a text input named `_hp`, hidden from people (off-screen with CSS, `tabindex="-1"`, `autocomplete="off"`) and always sent empty. A bot that fills it gets a normal-looking `202` and nothing is stored or sent.

**Cloudflare Turnstile.** When `captcha` is not `null`, render the Turnstile widget with `captcha.site_key` and send its token as `_captcha`. The token is checked against the store's registered websites. When `captcha` is `null`, the store's forms are not captcha-protected and you send no `_captcha`.

```html
<div class="cf-turnstile" data-sitekey="0x4AAAAAAA…"></div>
<input type="text" name="_hp" value="" tabindex="-1" autocomplete="off" class="visually-hidden" aria-hidden="true">
```

The Turnstile widget puts its token in a field named `cf-turnstile-response`. Copy that value into `_captcha` when you build the request.

## Submit the answers [#submit-the-answers]

Post the answers keyed by field handle, plus the protocol fields:

| Field | What it is |
|---|---|
| `_hp` | The honeypot. Always empty. |
| `_captcha` | The Turnstile token, when the form has a captcha. |
| `_page` | The page the form was on, as a path or URL. Only the path is kept. |
| `utm_source`, `utm_medium`, `utm_campaign`, … | Campaign values from the landing page, if you kept them. |

Send an `Idempotency-Key` so a double click or a retried request records one submission. If the shopper has a basket, send its `X-Cart-Token` too: the lead then picks up the basket's campaign source.

```bash tab="cURL"
curl -X POST https://api.elaraone.app/api/v1/forms/quote-request \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "X-Client-IP: 203.0.113.7" \
  -H "Idempotency-Key: 4b7f0c1e-2d3a-4e5f-8a9b-0c1d2e3f4a5b" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Katherine Johnson",
    "email": "katherine@example.com",
    "budget": "£500 to £2,000",
    "news": true,
    "_hp": "",
    "_captcha": "0.Zx…",
    "_page": "/quotes/kitchen-tables",
    "utm_source": "newsletter",
    "utm_medium": "email",
    "utm_campaign": "autumn"
  }'
```

```ts tab="TypeScript"
// app/api/forms/[handle]/route.ts
export async function POST(req: Request, ctx: { params: Promise<{ handle: string }> }) {
  const { handle } = await ctx.params
  const answers = await req.json()

  const res = await fetch(`${ENGINE}/forms/${encodeURIComponent(handle)}`, {
    method: 'POST',
    headers: {
      ...engineHeaders(),
      'Content-Type': 'application/json',
      'Idempotency-Key': req.headers.get('Idempotency-Key') ?? crypto.randomUUID(),
      'X-Client-IP': shopperIp(req), // the visitor's IP, for rate limits and Turnstile
    },
    body: JSON.stringify(answers),
  })
  return Response.json(await res.json(), { status: res.status })
}
```

A successful submission answers `202`:

```json
{ "data": { "result_id": 5120 } }
```

| Status | Code | When |
|---|---|---|
| `422` | `VALIDATION_FAILED` | A field failed its rules. `details` is keyed by field handle. A failed captcha is reported on `_captcha`. |
| `503` | `CAPTCHA_UNAVAILABLE` | Turnstile gave no answer. Nothing was stored. Ask the shopper to try again shortly. |
| `404` | `NOT_FOUND` | The form does not exist or is switched off. |
| `429` | `RATE_LIMITED` | More than 30 submissions a minute from one visitor. |

## Marketing consent [#marketing-consent]

A checkbox with `marketing_consent: true` is the form's marketing opt-in. Render it unticked, separate from any terms box. Whatever the shopper chooses is recorded on the contact as proof of consent, and marketing messages sent from the CRM check it before they go.

If someone who earlier asked the store to erase their data ticks the box again, the engine asks them to confirm before lifting the block:

- **By email:** they get a link to the store's marketing consent confirmation page, with `?token=…`. That page shows a **Confirm** button which posts `{ "token": "…" }` to `POST /v1/marketing/confirm`. Post on the button press, never on page load.
- **By text:** if they gave a phone number, they get a 6-digit code. After any consenting submission with a phone number, offer an "Enter the code we texted you" step that posts `{ "phone": "…", "code": "123456" }` to the same route.

Show the code step to everyone who consented with a phone number: the form's answer never says whether a code was sent. Success is `{ "data": { "status": "confirmed" } }`; anything else is `422 CONSENT_CONFIRMATION_INVALID`. Email links last 24 hours and codes 15 minutes.

## What happens next [#what-happens-next]

When a submission is accepted:

1. **A contact** is created or updated in the CRM, with the page and campaign it came from.
2. **A lead** opens for a new contact, or for a known contact with no open lead, and the store's lead routing rules assign it. Newsletter sign-up forms are the exception: they create subscribers, not leads.
3. **Email notifications** go out, up to 10 per form, each with its own recipients (the store admin, the person who filled it in, the lead's owner, chosen staff or other addresses), subject, message and choice of fields.
4. **Flows** with the **Form submitted** trigger start, filtered by form if they ask. See [Flows](/docs/guides/flows).

A honeypot hit or a failed captcha does none of these.
