<!-- https://docs.poily.com/forms · source: Apps/handoffs/admin_mkt/POILY_FORMS_EMBED_PLAYBOOK.md (client-facing cut) · last synced: 2026-11-02 -->
# Poily Forms Integration

*How to put a Poily-connected form on any website: three integration tiers, one data destination. Written for the developer or AI agent building the host site. No API keys, no CORS setup, no backend work on your side.*

## Mental model

**Poily** is the forms/CRM platform behind the product you're integrating with. The product's operator manages forms at `app.poily.com`; each product has a **forms domain** (e.g. `forms.<product>.com`) that serves the loader scripts and the hosted form renderer. Every tier below submits to the same Poily gateway, so submissions land in the same place with the same attribution regardless of how you embed.

```
your page
  └─ loader script from forms.<product>.com
        └─ form (iframe, web component, or your own markup)
              └─ POST → Poily gateway
                    └─ visible to the operator at app.poily.com → Submissions
```

Tenant identity rides on the **form slug** and, for scan-to-create, on your page's **origin** — never on the attribute names. The `poily-*` attribute names, the `window.__poilyOpenForm` global, and the event names are the same for every product. **Do not rename the `poily-` prefix** to match the product.

> **⚠️ The #1 trap: a success toast is not proof.**
> A legacy pre-Poily backend can return 200 and a success message while writing to the wrong database. Always verify in DevTools → Network that the submit POST hits the Poily gateway host (it contains `ju1sxvytc1`), then confirm the row in `app.poily.com → Submissions`. The gateway's `/dev` stage is correct and expected — don't go hunting for a "prod" URL.

## Choosing a tier

Two decisions, not one: *who renders the form* (Poily-hosted vs your own markup) and *what shape* (traditional all-at-once vs conversational one-question-at-a-time; conversational exists only on Poily-hosted forms). The data lands identically either way — optimize for ownership and design, not plumbing.

| Tier | Design control | Who edits fields | Effort | Best for |
|---|---|---|---|---|
| **1 · iframe embed** | Low (Poily's look) | Operator — no deploy | Lowest | Fastest launch; non-dev owns the form; one form on many sites |
| **2 · `<poily-form>`** | Medium (your CSS) | Operator | Low–med | Operator-owned fields with a closer visual fit |
| **3 · SDK / native form** | Full (your markup) | Code — deploy | Medium | Bespoke branded forms; custom UX; short stable field sets |

- **Branded waitlist or signup with few, stable fields** → native SDK (tier 3), traditional shape.
- **A non-technical owner edits the form, or it's reused across properties** → iframe (tier 1).
- **Operator-owned fields but a tighter visual fit** → `<poily-form>` (tier 2).
- **Long, qualifying, branching intake** → conversational shape, Poily-hosted (tier 1 or 2).
- **You already have a finished native form** → scan-to-create ([below](#scan-to-create-data-poily-import)) — Poily builds its side from your markup.

## 1 · iframe embed (fastest)

Get the canonical snippet from the operator (it comes from the form's **Distribution** tab in Poily and is copy-paste correct for the tenant). The whole integration is:

```html
<div data-poily-form data-slug="<slug>" data-mode="inline" data-placement="home_inline"></div>
<script src="https://forms.<product>.com/embed/forms.js" async></script>
```

Open a popup or slider from any button:

```html
<button onclick="window.__poilyOpenForm('<slug>', 'popup', undefined, 'dark', 'home_nav')">
  Get started
</button>
```

UTM/click-id/referrer capture, attribution, auto-resize, and theming are automatic — don't build your own lead-capture path. Multiple forms per page are supported.

### Attribute reference (`<div data-poily-form …>`)

| Attribute | Required | Default | Notes |
|---|---|---|---|
| `data-slug` | yes | — | form slug from the Distribution tab |
| `data-mode` | — | `inline` | `inline` \| `popup` \| `slider` \| `sidetab` |
| `data-placement` | — | — | per-CTA attribution tag — give each CTA its own |
| `data-label` | — | "Open Form" | button/tab text for popup, slider, sidetab |
| `data-color` | — | `#7c3aed` | trigger-button accent (hex) |
| `data-theme` | — | auto | `light` \| `dark`; auto follows the page — usually leave unset |
| `data-width` | — | `100%` | inline iframe width |
| `data-height` | — | `300px` | initial height only — the iframe auto-resizes after first paint |

### SPA / React notes

- The inline iframe injects into your `<div>` on page load — keep that div a plain, stable, empty element the framework never unmounts. (Popups attach to `document.body` and are immune.)
- Put the forms origin in an env var with no trailing slash; remember build-time env vars need a redeploy to change.
- `__poilyOpenForm` opens popup/slider only; `inline`/`sidetab` are container modes.

### Conversion tracking

```js
window.addEventListener('poily:form-submitted', (e) => {
  // e.detail = { type: 'poily-form-submitted', formTemplateId }
  // e.g. gtag('event','generate_lead') / fbq('track','Lead')
})
```

If your site runs a strict CSP, allow `forms.<product>.com` in `script-src` (loader) and `frame-src` (iframe). CORS keys off the iframe's own origin, so no allow-listing is needed on Poily's side for any host page.

## 2 · `<poily-form>` web component (your CSS)

Renders the Poily-defined form in your page's DOM — Poily owns schema, validation, submission, and attribution; you own the styling. One tag, no iframe, no React dependency:

```html
<script src="https://forms.<product>.com/poily-form.js" async></script>
<poily-form slug="<slug>" placement="home_hero"></poily-form>
```

Style from your own stylesheet — custom properties and parts pierce the shadow boundary:

```css
poily-form {
  --poily-font: 'Fraunces', serif;
  --poily-accent: #1a2b4a;
  --poily-field-radius: 8px;
  --poily-gap: 18px;
}
poily-form::part(button) { text-transform: uppercase; letter-spacing: .04em; }
poily-form::part(label)  { font-weight: 700; }
```

- **Custom properties:** `--poily-font` · `--poily-accent` · `--poily-text` · `--poily-muted` · `--poily-field-bg` · `--poily-field-border` · `--poily-field-radius` · `--poily-button-bg`/`-text`/`-radius` · `--poily-gap` · `--poily-label-color`/`-weight` · `--poily-error`
- **Parts:** `form` · `intro` · `field-grid` · `field` · `label` · `input` · `select` · `textarea` · `options` · `option` · `button` · `error` · `success` · `required`
- **Slots:** `<span slot="header">` / `<span slot="footer">` for your own microcopy.
- **Events (bubble):** `poily:ready {schema}`, `poily:submitted {id}`, `poily:error {error}`.
- On-brand by default (the form's configured styles seed it; your CSS wins). Two-column for half-width fields, collapsing on narrow screens. Attribution and honeypot are automatic.

## 3 · SDK — keep your own form (your markup)

When the site already has a pixel-perfect form, keep it: the SDK intercepts its submit, captures attribution from the real page, and sends the data to Poily. Your markup and CSS stay 100% native.

### Attach to an existing Poily form

```html
<script src="https://forms.<product>.com/sdk.js" async></script>
<form data-poily-attach="<slug>" data-poily-placement="home_hero">
  <input name="email" type="email" required>
  <input name="name">
  <input name="_gotcha" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px"><!-- honeypot -->
  <button>Request access</button>
</form>
```

```js
document.querySelector('form').addEventListener('poily:submitted', (e) => {
  // e.detail = { id } — fire your pixel here
})
```

Inputs map by `name` (falling back to `id`); keys starting with `_` are skipped, so the honeypot is ignored by design. Bot fills of `_gotcha` are dropped server-side.

### Programmatic

```js
// enhance an existing form (field→key mapping, callbacks)
Poily.attach(form, '<slug>', { placement: 'home', map: { your_email: 'email' }, onSuccess(r){} })

// …or fully custom UI built from the schema
const schema = await Poily.getSchema('<slug>')   // { fields: [{key,label,type,required,…}], … }
await Poily.submit('<slug>', { email, name }, { placement: 'home_hero' })
```

### No-JS fallback

A plain `<form action>` can point straight at the gateway's REST submit route (`/f/<slug>/submit`); ask the operator for the gateway URL, include the `_gotcha` honeypot, and the response 303-redirects back to your page. Prefer the SDK or component where you can.

## Scan-to-create: `data-poily-import`

*Zero form-building.* `data-poily-attach` needs a Poily form to already exist. **`data-poily-import` removes that step**: it reads your form's fields on first page load, auto-creates the matching Poily form, and then captures submissions to it. Nothing to build in Poily, no field mapping, no slug to copy — the lowest-effort path when the native form already exists.

```html
<script src="https://forms.<product>.com/sdk.js" async></script>
<form data-poily-import="alumni-signup" data-poily-placement="signup_page">
  <!-- your existing fields, unchanged — each needs a name OR an id -->
  <input name="first_name"> <input name="last_name">
  <input name="email" type="email" required>
  <select name="org_type"><option value="university">University</option>…</select>
  <button>Request access</button>
</form>
```

> **⚠️ Three preconditions — the only things that bite.**
>
> 1. **Run on the real deployed origin.** The tenant is resolved from the page's `Origin`, so `localhost` and preview URLs (e.g. `*.netlify.app`) return `403 unknown_origin`. Nothing to fix in code — test where the form actually lives (the product's registered custom domain or its `www.`/`forms.` variants).
> 2. **Validate on the live page in DevTools → Network, never by replaying requests.** A curl replay with hand-written field names can pass while the real page is unwired. The only proof: a real load showing `POST /f/import → 200`, a real submit showing `POST /f/<slug>/submit → 200`, then the row in `app.poily.com → Submissions`.
> 3. **If the submit POST never fires** (rare): the SDK captures on the capture phase, so it beats handlers that stop propagation. You only hit this when the form emits no `submit` event at all (a `type="button"` + onClick, no form submit) — then bind to the button inside the `poily:imported` event.

- **The import key is idempotent** — reloads and other pages using the same key return the same Poily form, never duplicates. Safe to leave on permanently.
- **The slug derives from your key** (stable, predictable). Need an exact one? `data-poily-slug="my-slug"`.
- **Additive sync:** a field you add to the markup later is appended to the Poily form on the next load; operator edits are preserved. A field you want gone must be deleted from the markup (builder deletions reappear on the next import).
- **JS-populated selects self-heal:** the SDK briefly waits for options and backfills on later loads; submitted values are always read live.
- On load the form fires `poily:imported` (`e.detail = {slug, created}`), then behaves exactly like `data-poily-attach`.
- The created form is published immediately and appears in Poily, where the operator can rename, restyle, or wire automations — import gives the form, Poily owns it from there.

### Make your source form import-ready

The submission key (`last_name`, `email`) is what lands in the data, so it should read like the capture you want — never a framework auto-id like `:r1:-email`. Checklist:

- An explicit, stable, snake_case `name` on every capture control (`first_name`, `email`, `company`, `role`).
- A real associated `<label>` (`for`/`htmlFor`) — it becomes the field's label, and (slugified) the fallback key.
- **Render every field at load** — collapsed or hidden is fine; conditionally-*mounted* fields aren't in the DOM at scan time and won't be captured.
- A `value=` on every `<select>` option and every checkbox/radio. Prefix throwaway fields with `_`.

**Generating the form with an AI agent?** Paste this into its prompt so the output is import-ready in one shot:

```
Make this lead form "Poily import-ready":
1. Every data field (input/select/textarea) has an explicit, stable, snake_case `name`
   describing the data (first_name, email, company, saas_url, role) — never a
   framework-generated id.
2. Every field has a real, associated <label> (htmlFor/for) with human-readable text.
3. Render all fields at initial load — no step-gated or conditionally-mounted fields.
   For multi-step UX, keep later fields in the DOM (hidden), not unmounted.
4. An explicit `value` on every <select> option and every checkbox/radio.
5. One <form> element with a real submit button, plus an off-screen honeypot
   input named `_gotcha`.
```

## Verification protocol — do not skip

1. Test in **incognito** with a hard refresh (a cached old renderer can stick for visited sessions).
2. Submit **every** entry point — each popup/slider CTA and the inline form.
3. **DevTools → Network:** the submit POST must hit the Poily gateway (host contains `ju1sxvytc1`; the iframe uses `/graphql`, the SDK and component use `/f/<slug>/submit`). Any other host is a legacy or wrong target — `/dev` in the path is correct.
4. Confirm the row in `app.poily.com → [tenant] → Submissions` (and the form's Responses view).
5. Verify `placement` and UTM/referrer populated on the row (open the page with `?utm_source=test` to check).
6. **Never trust the success toast alone.**

A live demo page showing all three tiers on one form is served at `https://forms.<product>.com/demo.html?slug=<slug>`.

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| Nothing renders / blank iframe | Your page's CSP blocks the loader or iframe | Allow `forms.<product>.com` in `script-src` and `frame-src`; confirm `/embed/forms.js` returns 200 |
| `__poilyOpenForm is not defined` | The loader hadn't loaded when the button fired, or is CSP-blocked | Confirm the script tag is present and returns 200; don't gate the CTA on a not-yet-loaded loader |
| Submit "succeeds" but nothing in Poily | POST hit a legacy pre-Poily backend, or a change wasn't redeployed | Verify the POST host is the Poily gateway; point the loader at the Poily forms domain; redeploy |
| Inline fails but popup works (or vice versa) | Stale cached inline iframe, or the SPA unmounted the embed div | Retest in incognito; keep the embed div stable |
| `403 unknown_origin` on import | Testing from localhost or a preview URL | Test on the product's registered domain (see scan-to-create preconditions) |
| Renderer looks old or wrong | CDN cache, or the forms domain is bound to the wrong tenant | Escalate to the product's Poily operator |

**Escalate to the product's Poily operator** when the POST hits a non-Poily gateway even after a clean redeploy, or when submissions return 200 from the Poily gateway but don't appear in Submissions — both are platform-side.

---

*Poily forms reference for host-site developers and agents. The attribute names, globals, and events shown here are platform-wide and identical for every product — only the forms domain and the slug are yours. Questions beyond this page go to the product's Poily operator.*
