# Embed a form

> Add a Caramel form to any site with a one-line iframe snippet, or render it natively with your own components.

Publish a Caramel form on any site via an iframe snippet (zero code) or by fetching the form schema and rendering it with your own components. Both approaches send submissions through the same pipeline — contacts are created, journeys are enrolled, and compliance is logged identically.

## Before you begin

- Create and publish a form. Open **Forms → New form**, add your fields, and mark it active.
- For the native render approach, complete [authentication](../get-started/authentication) to obtain an access token.

## Approach 1 — iframe embed

Every active form has a hosted URL:

```
https://app.caramelme.com/f/<form-slug>
```

Get the slug from **Forms → Share**, or via `caramel.v1.form.list` (`form.slug`). Embed:

```html
<iframe
  src="https://app.caramelme.com/f/brewclub-signup"
  width="100%"
  height="500"
  frameborder="0"
  title="Sign up for BrewClub"
></iframe>
```

The hosted form is branded with your business logo and colors (set in **Settings → Branding**), localized to the visitor's browser locale, and handles file upload fields.

Limitations of the iframe approach:
- Style overrides are limited to what the dashboard exposes.
- Custom validation messages aren't possible.
- Your app's state management and analytics aren't connected by default.

### Listen for submission events

The iframe posts a `postMessage` to the parent window on success:

```js
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://app.caramelme.com') return; // mandatory origin check
  if (event.data.type === 'caramel.form.submitted') {
    const { submission_id, contact_id } = event.data;
    // Redirect, show a toast, update local state, etc.
  }
});
```

> **Important** The origin check is mandatory. Without it, any page can spoof submission events.

That's the full iframe story. If your design allows it, this takes 5 lines of code.

## Approach 2 — Native render

Fetch the form schema, render it with your own components, and submit via the REST gateway.

| Approach | Best for | Effort |
|---|---|---|
| iframe | Minimal setup, file uploads, localization out of the box | 1 line of HTML |
| Native render | Pixel-perfect styling, custom validation, app state integration | 50–100 lines |

### Step 1 — Fetch the form schema

```js
const res = await fetch('https://gateway.caramelme.com/v1/forms/list', {
  method:  'GET',
  headers: { 'Authorization': `Bearer ${TOKEN}` },
  body:    JSON.stringify({ businessId: BUSINESS_ID }),
});
const { forms } = await res.json();
const form = forms.find(f => f.slug === 'brewclub-signup');
// form.fields — array of field definitions
```

```py
import httpx

res = httpx.get(
    'https://gateway.caramelme.com/v1/forms/list',
    headers={'Authorization': f'Bearer {TOKEN}'},
    json={'businessId': BUSINESS_ID},
)
form = next(f for f in res.json()['forms'] if f['slug'] == 'brewclub-signup')
```

Cache the schema. Field definitions change rarely.

### Step 2 — Render with your components

A React example with `react-hook-form`:

```tsx
import { useForm } from 'react-hook-form';

type FieldDef = {
  name:     string;
  type:     'text' | 'email' | 'phone' | 'checkbox' | 'select' | 'textarea';
  label:    string;
  required: boolean;
  options?: { value: string; label: string }[];
};

function CaramelForm({ form }: { form: { id: string; fields: FieldDef[] } }) {
  const { register, handleSubmit, setError, reset,
          formState: { errors, isSubmitting } } = useForm();

  const onSubmit = async (data: Record<string, unknown>) => {
    const res = await fetch('https://gateway.caramelme.com/v1/forms/submit', {
      method:  'POST',
      headers: {
        'Authorization': `Bearer ${TOKEN}`,
        'Content-Type':  'application/json',
      },
      body: JSON.stringify({
        formId:    form.id,
        data,
        sourceUrl: window.location.href,
        utmParams: readUtm(),
      }),
    });

    if (res.status === 422) {
      const err = await res.json();
      err.details.forEach((d: { field: string; reason: string }) =>
        setError(d.field, { type: d.reason })
      );
      return;
    }
    if (!res.ok) {
      const err = await res.json();
      alert(err.message ?? 'Submission failed');
      return;
    }

    const result = await res.json();
    // result.submission_id, result.contact_id, result.enrolled_in
    reset();
    alert(`Welcome aboard! You've been enrolled in ${result.enrolled_in.join(', ')}.`);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {form.fields.map(field => (
        <Field key={field.name} field={field} register={register} error={errors[field.name]} />
      ))}
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting…' : 'Submit'}
      </button>
    </form>
  );
}
```

### Step 3 — Handle the response

A successful submission returns:

```json
{
  "submission_id": "sub_01h7m9k0aabcdefghj1234567",
  "contact_id":    "con_01h7m9k0aabcdefghj1234567",
  "form_id":       "frm_01h7m9k0aabcdefghj1234567",
  "enrolled_in":   ["jrn_welcome_v3"],
  "created_at":    "2026-06-06T14:23:11Z"
}
```

Log `submission_id` for support lookups. Use `contact_id` to reference the contact in your admin UI. Use `enrolled_in` to confirm which journeys the person entered.

## Common patterns

### Capture UTM parameters

Pass UTM data with every submission so it's available in campaign segments:

```tsx
function readUtm() {
  const p = new URLSearchParams(window.location.search);
  const utm = {
    source:   p.get('utm_source')   ?? undefined,
    medium:   p.get('utm_medium')   ?? undefined,
    campaign: p.get('utm_campaign') ?? undefined,
    term:     p.get('utm_term')     ?? undefined,
    content:  p.get('utm_content')  ?? undefined,
  };
  return Object.values(utm).every(v => !v) ? undefined : utm;
}
```

Include `utmParams: readUtm()` in the submit request body.

### Prefill from query parameters

For invitation flows, set form defaults from the URL:

```tsx
const urlParams = new URLSearchParams(window.location.search);
const form = useForm({
  defaultValues: {
    email:      urlParams.get('email')      ?? '',
    first_name: urlParams.get('first_name') ?? '',
  },
});
```

> **Important** Always re-validate prefilled email server-side. Anyone can forge `?email=victim@example.com`.

### Localize field labels

```tsx
function label(field: FieldDef, locale: string): string {
  return field.label_i18n?.[locale] ?? field.label;
}
```

Fallback chain: `label_i18n[locale]` → `label` (English source). Apply consistently — mixed-language forms create a confusing experience.

### Client-side honeypot

Add a hidden field and reject submissions where it's filled before hitting the API:

```tsx
<input
  type="text"
  name="website"
  style={{ position: 'absolute', left: '-10000px' }}
  tabIndex={-1}
  autoComplete="off"
  {...register('website')}
/>
```

Check it in `onSubmit` and return early if `data.website` is non-empty.

## Limitations

> **Coming soon** The following are not yet supported via the public API:

- **File upload fields** — use the iframe embed, which handles uploads natively.
- **Multi-step wizard forms** — collect data across multiple pages in your app, then submit all of it in a single call at the end.
- **Conditional field logic** — implement show/hide logic client-side; the server treats all fields independently.

## Testing locally

Set your token and business ID in `.env`:

```
VITE_CARAMEL_TOKEN=eyJhbGciOi...
VITE_CARAMEL_BUSINESS_ID=bus_01h7…
```

Submit to a test form (not your production form). Verify the submission appears under **Forms → Submissions** in the dashboard.

> **Important** Do not expose the access token in a client-side bundle in production. Proxy form submissions through your backend so the token stays server-side.

## Next steps

- [Sync contacts](sync-contacts) — bulk-import an existing audience.
- [Rate limits](../reference/rate-limits) — per-tier submission caps and backoff guidance.
- [`caramel.v1.form.submit` reference](../reference/tools) — every parameter and error code.
