Skip to content

Embed a form

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.

  • Create and publish a form. Open Forms → New form, add your fields, and mark it active.
  • For the native render approach, complete authentication to obtain an access token.

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:

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

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

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.

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

ApproachBest forEffort
iframeMinimal setup, file uploads, localization out of the box1 line of HTML
Native renderPixel-perfect styling, custom validation, app state integration50–100 lines
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
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.

A React example with react-hook-form:

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>
);
}

A successful submission returns:

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

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

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.

For invitation flows, set form defaults from the URL:

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.

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.

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

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

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.

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.