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.
Before you begin
Section titled “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 to obtain an access token.
Approach 1 — iframe embed
Section titled “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:
<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
Section titled “Listen for submission events”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.
Approach 2 — Native render
Section titled “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
Section titled “Step 1 — Fetch the form schema”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 definitionsimport 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
Section titled “Step 2 — Render with your components”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> );}Step 3 — Handle the response
Section titled “Step 3 — Handle the response”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.
Common patterns
Section titled “Common patterns”Capture UTM parameters
Section titled “Capture UTM parameters”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.
Prefill from query parameters
Section titled “Prefill from query parameters”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.
Localize field labels
Section titled “Localize field labels”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
Section titled “Client-side honeypot”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.
Limitations
Section titled “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
Section titled “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
Section titled “Next steps”- Sync contacts — bulk-import an existing audience.
- Rate limits — per-tier submission caps and backoff guidance.
caramel.v1.form.submitreference — every parameter and error code.