# Custom Forms and Network Access

> Collect any form from a page, style it freely, and call approved APIs from section JS.

Canonical URL: https://3c5b211cae60:3005/guides/custom-forms-and-network

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** setting (server-side) |
| Call another API from the page | `lifecycle.fetch` on an origin allowed in **Settings → Network access** |

## Forms from section JS

Every custom section's script receives `lifecycle.forms`:

```js
// Live field definitions from the Forms dashboard (null if the form doesn't exist).
const form = await lifecycle.forms.schema("customer_phone_number");

// Submit. Never throws: check ok, then code/field for errors.
const result = await lifecycle.forms.submit(
  "customer_phone_number",
  { customer_phone: input.value },
  { consent: checkbox.checked },
);
if (!result.ok) showError(result.field, result.code);
```

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.
- **Unknown or archived forms** return `unknown_form` or `form_archived`.

| 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.

## Network access

Section JS cannot call `fetch` directly. Instead, add the API's origin in
**Settings → Network access** and use `lifecycle.fetch`:

```js
const res = await lifecycle.fetch("https://api.example.com/v1/stock?sku=123");
const data = await res.json();
```

`lifecycle.fetch` returns the normal `Response`. Each call:

- must match an enabled rule: an `https` origin (or `*.domain` wildcard, or
  `self` for the store's own domain), a path prefix, and the HTTP method;
- sends no cookies unless the rule is `self` with `same-origin`;
- refuses redirects, times out after 10 seconds, and is cancelled when the
  section is removed;
- is limited to 30 calls per minute per section.

`self` can never reach `/cart`, `/checkout`, `/account` or Lexsis's own
`/a/lp/lx-*` routes: use the cart commands and `lifecycle.forms` for those.

When the URL is written as a literal, publishing checks it against the rules
and blocks the publish if no rule allows it. Dynamic URLs are checked when the
call runs.

<Callout type="warn">
Anything written in section code — including API keys — is visible to every
shopper. Use a proxied connection for anything secret.
</Callout>

### Direct calls need CORS

A direct rule only works if the API allows browser calls from the store's
domain. The **Test a URL** box in Network access checks this and suggests a
proxied connection when it fails.

## Proxied connections

A proxied connection keeps the API key on the server. In Network access, add a
rule with mode **Proxied**, give it a slug, and say where the key goes, for
example header `Authorization` with template `Basic {{secret}}`. Then paste the
key once. It's stored encrypted and only the last four characters are ever
shown again.

Section JS calls it by slug:

```js
await lifecycle.fetch("lx:connection/whatsapp/track/users", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ phoneNumber, countryCode: "+91" }),
});
```

Golem adds the key and calls `origin + path_prefix + /track/users`. The path
stays under the rule's prefix. With **allowed body keys** set, any other keys
are stripped. Only the upstream `content-type` comes back. Upstream redirects,
private addresses and responses over 1 MB are refused. Calls are limited per
shopper (20/min per connection) and per store (600/min). In previews the
connection returns `{"dry_run": true}` without calling the API.

## Forward entries server-side

To send every accepted entry to a connection, 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.
