# Forms

> lifecycle.forms.schema and lifecycle.forms.submit — send entries to any Forms-dashboard form from section JS, plus server-side forwarding and privacy.

Canonical URL: https://77865786950d:3005/scripting/forms

A page can send entries to any form created in the Forms dashboard. Pick the
approach that fits:

| Need | Use |
|---|---|
| A standard form styled with CSS | [`FormCapture` island](/islands/engagement/form-capture) |
| Fully custom markup, layout and behaviour | Section JS with `lifecycle.forms` |
| Send the entry to WhatsApp, a CRM or Klaviyo | The form's [`forward_to`](#forward-entries-server-side) setting |
| Call another API from the page | [`lifecycle.fetch`](/scripting/network) |

```js
const form = await lifecycle.forms.schema("customer_phone_number");

const result = await lifecycle.forms.submit(
  "customer_phone_number",
  { customer_phone: input.value },
  { consent: checkbox.checked },
);
if (!result.ok) showError(result.field, result.code);
```

## lifecycle.forms.schema

```ts
lifecycle.forms.schema(formKey: string): Promise<FormDefinition | null>
```

The form's live field definitions, or `null` if the form doesn't exist or the
lookup failed. Results are cached for the page view; a failed lookup is
retried on the next call.

```ts
interface FormDefinition {
  form_key: string;
  name: string;
  fields: Array<{
    key: string;
    type: string;
    label: string;
    required?: boolean;
    placeholder?: string | null;
    options?: string[] | null;
    default_country?: string | null;
  }>;
  consent_text?: string | null;
}
```

## lifecycle.forms.submit

```ts
lifecycle.forms.submit(
  formKey: string,
  data: Record<string, unknown>,
  options?: { consent?: boolean },
): Promise<{ ok: boolean; code?: string; field?: string; message?: string }>
```

Never throws: check `ok`, then `code` and `field` for errors. The entry is
linked to the shopper's visitor and session, so it can be joined to their cart
and abandoned checkouts.

The server validates every entry against the form's live schema:

- **Phone fields** accept a 10-digit Indian number or E.164 and are stored as
  `+919876543210`. Set a field's `default_country` for other regions.
- **Email, number, select, radio and checkbox** are type-checked. Keys the form
  doesn't declare are dropped, and values are capped at 2,000 characters.
- **Consent:** when the form has consent text, `{ consent: true }` is required.
  The text and the time it was accepted are stored with the entry.

| Error `code` | Meaning |
|---|---|
| `required` | A required field is empty (`field` names it) |
| `invalid_phone`, `invalid_email`, `invalid_number`, `invalid_option`, `invalid_checkbox`, `too_long` | A field failed validation |
| `consent_required` | The form needs consent |
| `unknown_form`, `form_archived` | The form key isn't usable |
| `rate_limited` | Too many submissions from this shopper (about 10/min) |
| `network_error`, `submit_failed` | Try again |

Entries from the editor and draft previews are stored as **test entries**:
they are hidden from the Forms list and CSV by default, and never forwarded.

## Forward entries server-side

To send every accepted entry to a [proxied connection](/scripting/network#proxied-connections),
without any section JS, set the form's `forward_to`:

```json
{
  "consent_text": "Send me offers on WhatsApp",
  "forward_to": [
    {
      "connection": "whatsapp",
      "path": "track/users",
      "method": "POST",
      "body": {
        "phoneNumber": "{{customer_phone}}",
        "traits": { "source": "{{form_key}}" }
      }
    }
  ]
}
```

`{{field_key}}`, `{{form_key}}` and `{{submitted_at}}` are filled from the
entry. A value that is exactly one placeholder keeps its type. Without a
`body`, the entry is sent as-is.

Forwarding runs after the shopper's submit has succeeded. It retries up to
three times on server errors, and records the outcome on the entry under
`metadata.forwards`. A failed forward never fails the shopper's submission.

## Privacy

Entries store the hashed email and phone. A Shopify customer redact request
deletes matching entries, and a data request exports them. Merchants can also
delete a single entry. Agent reads (`lexsis_capture.submissions`) redact every
field typed email or phone, whatever its key.

See [Forms tools](/tools/forms) for creating and reading forms through MCP.
