# Form submit (email capture)

Two ways to capture an email and feed it into a Zalify email list.

| Scenario | What you write | What the pixel does |
|---|---|---|
| **A. Popup with a form** | A popup HTML body containing `<form data-zalify-form>`, with `form.list_id` set on the popup doc | Automatically wires the form: POSTs to submit endpoint, fires `form_submitted` + `lead`, drives `idle → submitting → success | error` state |
| **B. Your own form (no popup)** | A normal `<form>` on your storefront + one call to `zalify('wire_form', { selector, list_id })` | The pixel reuses the popup's wiring against your form: POSTs, fires `form_submitted` + `lead`, drives the same state machine |

Pick **A** when you want Zalify to render and own the UI. Pick **B** when you already have an email-capture form on your site (footer signup, dedicated landing page, hero CTA) and just want submissions to land in a Zalify list with `lead` / `form_submitted` events firing alongside.

## The submit endpoint

Both scenarios POST to one public endpoint, `unisubmit`, hosted at `reach.zalify.com`. A single call records a **Submission** against a Form and — when a `subscribe` block is present — adds the identity to a **List** (which can in turn trigger an Automation Flow):

```
POST https://reach.zalify.com/v1/public/unisubmit
Content-Type: application/json

{
  "wid": "<workspace_id>",
  "submission": {
    "form_key": "<form_key>",
    "payload": { "email": "buyer@example.com", "firstName": "Ada" }
  },
  "subscribe": {
    "list_id": "<list_id>",
    "email_marketing_consent": true
  },
  "identity": {
    "email": "buyer@example.com",
    "phone": "+15551234567",
    "first_name": "Ada",
    "last_name": "Lovelace"
  },
  "context": { "page_url": "https://store.example.com/newsletter", "visitor_id": "<pixel visitor id>" },
  "idempotency_key": "<optional, for safe retries>"
}
```

- **No bearer token** — public endpoint, cross-origin from your storefront, cookieless (`credentials: 'omit'`).
- **`wid`** (top-level) is the Zalify workspace id — the same value the pixel is initialized with; it scopes the submission to your tenant. Both `wire_form` and the popup runtime fill this in for you automatically.
- **`submission.form_key`** is the **Form** (hosted or self-owned) this submit is recorded against; every call is stored as one **Submission** on that Form. `submission.payload` keeps your raw fields verbatim, so it doubles as survey / lead-gen capture.
- **`subscribe`** (optional) adds the identity to a **List**: `list_id` is the destination (dashboard → Customers → Lists, a UUID like `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`), `email_marketing_consent` records the opt-in. Omit the whole block to log a Submission without subscribing anyone.
- **`identity`** needs at least `email` or `phone`; `first_name` / `last_name` are optional. It's the profile added to the List and matched into **Segments**.
- **`context`** carries `page_url` and the pixel `visitor_id` (`window.zalify?.vid`) so the submission stitches to that visitor's other pixel events.
- **`idempotency_key`** (optional) makes retries safe rather than double-counted; the pixel's popup / `wire_form` paths add one for you.

See [llms.txt](llms.txt) → "Data model" for how Forms, Lists, Segments, Submissions, Flows, and Broadcasts fit together.

## Scenario A — popup with a form

See [popup-schema.md](popup-schema.md) for the full authoring contract. Short version:

1. Set `form.list_id` on the popup doc.
2. In the popup HTML, wrap inputs in `<form data-zalify-form>` and use a `<button type="submit">`.
3. Inputs use the wire-key names: `email`, `phone`, `firstName`, `lastName`, `language`, `emailMarketingConsent`, `smsMarketingConsent`.

That's it — the pixel handles the rest.

## Scenario B — wiring your own form

You write the HTML; the pixel handles the rest. One call to `zalify('wire_form', { selector, list_id })` attaches the same submit-handling logic the popup runtime uses — POSTs to the submit endpoint, fires `form_submitted` + `lead`, and drives the `idle → submitting → success | error` state machine on the form element.

```html
<form id="zf-newsletter" data-zalify-form>
  <input name="email" type="email" required placeholder="you@example.com" />
  <input name="firstName" type="text" placeholder="First name" />
  <label><input name="emailMarketingConsent" type="checkbox" checked /> Send me promos</label>
  <button type="submit">Subscribe</button>
</form>

<div data-zalify-form-show="success" hidden>Thanks — check your inbox.</div>
<div data-zalify-form-show="error" hidden>Something went wrong. Please try again.</div>

<script>
  window.zalify('wire_form', {
    selector: '#zf-newsletter',
    list_id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
    form_id: 'footer_signup', // optional analytics label; defaults to the selector
  });
</script>

<style>
  form[data-zalify-form-state="submitting"] button { opacity: .55; pointer-events: none; }
  form[data-zalify-form-state="success"] { display: none; }
</style>
```

### Authoring contract

- **Input `name` attributes** map 1:1 to the wire keys: `email`, `phone`, `firstName`, `lastName`, `language`, `emailMarketingConsent`, `smsMarketingConsent`. Other inputs are silently dropped.
- **`data-zalify-form`** on the `<form>` opts it in. If you forget, `wire_form` adds it for you when you pass the form itself as the selector — but explicit is better.
- **`data-zalify-form-show="success"` / `="error"`** on sibling elements (rendered with `hidden`) lets you declare per-state panels without writing JS. The runtime toggles their `hidden` attribute.
- **`form_id`** is optional. Defaults to the selector string. Set it explicitly when the same selector matches multiple forms or when you want a stable label that doesn't change with CSS selector edits.

### Timing & idempotency

- **Call after the form exists in the DOM.** For server-rendered pages, an inline `<script>` placed *after* the form works. For client-rendered pages (React/Vue/etc.), call inside `useEffect` / `onMounted` once the form is in the tree.
- **Calling twice on the same form is a no-op.** The shared wiring checks `data-zalify-form-state` and skips already-wired forms — safe across SPA navigations or rerenders.
- **The command itself queues behind boot**, so it's safe to call before the pixel finishes initializing. It runs as soon as `init` resolves.

### Origin allowlist

If your workspace enforces an origin allowlist, add your storefront origin to it (dashboard → workspace settings) before submissions from a new origin will go through. Otherwise the endpoint rejects the request and the form lands in the error state.

### Why this shape

- **`credentials: 'omit'`** on the POST — the submit endpoint doesn't need cookies, and including them creates needless CORS complications when origin allowlists are involved.
- **Fire `form_submitted` *before* the POST.** If the network fails or the user closes the tab mid-submit, you still know they attempted. Treat `form_submitted` as "intent captured" and `lead` as "contact saved."
- **Fire `lead` only on 2xx.** Downstream conversion attribution (Facebook, Reddit, Google) reads `lead` as a real signup event. Letting it fire on failure would inflate conversion counts.

### DIY fallback

If you'd rather not depend on the pixel for form wiring (e.g. you have your own analytics layer and just want the submit endpoint), POST directly:

```js
await fetch('https://reach.zalify.com/v1/public/unisubmit', {
  method: 'POST',
  credentials: 'omit',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    wid: '<workspace_id>',
    submission: { form_key: '<form_key>', payload: { email, firstName } },
    subscribe: { list_id: '<list_id>', email_marketing_consent: true },
    identity: { email },
    context: { page_url: location.href, visitor_id: window.zalify?.vid },
  }),
});
// Then fire your own form_submitted + lead events with zalify('track', ...) if you want them in the Zalify pipeline.
```

Everything `wire_form` does on top of this is convenience: payload collection, state machine, panel toggling, idempotency.

## Event payloads

Both scenarios fire the same two events into the Zalify pipeline:

```ts
// form_submitted — intent captured, fires on submit before the POST
{
  event: "form_submitted",
  payload: {
    form_id: string,           // popup id (Scenario A) or your own id (Scenario B)
    list_id: string,           // destination list
    email?: string,
    phone?: string,
    firstName?: string,
    lastName?: string,
    emailMarketingConsent?: boolean,
    smsMarketingConsent?: boolean,
  }
}

// lead — contact saved, fires only on 2xx response
{
  event: "lead",
  payload: {
    email?: string,
  }
}
```

The `lead` payload is intentionally minimal so it maps cleanly to the lead/conversion event shape that downstream ad platforms (Facebook, Reddit, Twitter, GA4) expect. Use `form_submitted` for the richer payload.
