Forms and audience
A form is a data-capture endpoint. Submit to a form and Caramel validates the fields, upserts the contact, evaluates segment membership, and optionally enrolls the contact in a journey — all in one call.
The contact object
Section titled “The contact object”Contacts are the audience. Each belongs to one business. Email is the primary key: 2 submissions with the same email update the same contact, never create a duplicate.
{ "id": "con_01h7m9k0aabcdefghj1234567", "business_id": "bus_01h7m9k0aabcdefghj1234567", "email": "jane@example.com", "first_name": "Jane", "last_name": "Adamson", "locale": "fr", "phone": "+33612345678", "marketing_consent": true, "source": "form_submit", "tags": ["vip", "newsletter"], "created_at": "2026-06-06T14:23:11Z"}| Field | Description |
|---|---|
email | Primary key within a business. Required on every contact. |
first_name, last_name | Used by templates for personalization. |
locale | Preferred message language. Inherits from the business if not set. |
phone | Required for SMS or WhatsApp delivery. |
marketing_consent | GDPR/CAN-SPAM opt-in flag. false blocks marketing messages (not transactionals). |
source | How the contact entered the audience. Set on creation; does not change on subsequent updates. |
tags | Free-form labels. Used by segment definitions. |
Caramel always lowercases emails server-side. Jane@Example.com matches an existing jane@example.com.
How contacts enter the audience
Section titled “How contacts enter the audience”There are 3 paths:
- Form submission (
caramel.v1.form.submit) — the most common. A submission upserts the contact based on the form’s field schema. - Direct upsert (
caramel.v1.contact.upsert) — use this when your app collects data outside a form (for example, a custom onboarding wizard or OAuth callback). Requires theaudience:writescope and Business tier or above. - Integration sync — connected CRM integrations sync contacts in from the source system. Configured through the dashboard.
All 3 paths produce the same contact record. They differ only in the source field.
Submit to a form
Section titled “Submit to a form”curl -X POST https://gateway.caramelme.com/v1/forms/submit \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "formId": "frm_01h7m9k0aabcdefghj1234567", "data": { "email": "jane@example.com", "first_name": "Jane", "favorite_drink": "flat white" }, "sourceUrl": "https://brewclub.app/signup", "utmParams": { "source": "instagram", "campaign": "spring2026" } }'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: 'frm_01h7m9k0aabcdefghj1234567', data: { email: 'jane@example.com', first_name: 'Jane', favorite_drink: 'flat white' }, sourceUrl: 'https://brewclub.app/signup', utmParams: { source: 'instagram', campaign: 'spring2026' }, }),});const result = await res.json();import httpxres = httpx.post( 'https://gateway.caramelme.com/v1/forms/submit', headers={'Authorization': f'Bearer {TOKEN}'}, json={ 'formId': 'frm_01h7m9k0aabcdefghj1234567', 'data': {'email': 'jane@example.com', 'first_name': 'Jane', 'favorite_drink': 'flat white'}, 'sourceUrl': 'https://brewclub.app/signup', 'utmParams': {'source': 'instagram', 'campaign': 'spring2026'}, },)What happens on the server
Section titled “What happens on the server”- Validate. Every required field is present; every typed field matches its type.
- Upsert the contact. Email is the key. Existing contacts are updated; new contacts are created.
- Apply marketing consent. If the form has a
marketing_consent_field, the checkbox value is written to the contact. - Record the submission. Stored with
source_url,utm_params, hashed IP, and timestamp. Counts toward the monthly submission cap. - Evaluate segments. The contact’s segment memberships are re-evaluated immediately.
- Enroll in journeys. Any active journey whose entry segment matches the contact begins enrollment. This step is asynchronous — the contact appears in the journey within seconds of the response.
- Send notification (optional). If
notification_emailis set on the form, a submission summary email is sent to that address.
Response
Section titled “Response”{ "submission_id": "sub_01h7m9k0aabcdefghj1234567", "contact_id": "con_01h7m9k0aabcdefghj1234567", "form_id": "frm_01h7m9k0aabcdefghj1234567", "enrolled_in": ["jrn_welcome_v3", "jrn_birthday_sequence"], "created_at": "2026-06-06T14:23:11Z"}enrolled_in lists the journey IDs the contact entered as a result of this submission.
Validation errors
Section titled “Validation errors”A submission with missing or invalid fields returns 422 validation:
{ "error": "validation", "message": "One or more required fields missing or invalid.", "status": 422, "details": [ { "field": "email", "reason": "format" }, { "field": "first_name", "reason": "required" } ]}| Reason code | Meaning |
|---|---|
required | Field is required but missing or empty. |
format | Type validation failed (e.g. invalid email, non-numeric value in a number field). |
length | String length below the minimum or above the maximum. |
pattern | Value doesn’t match a regex constraint. |
enum | Value is not one of the allowed radio or select options. |
consent_required | A required consent checkbox was not checked. |
Reason codes are stable — match them as strings to render inline errors next to your form fields.
Marketing consent
Section titled “Marketing consent”marketing_consent is the GDPR/CAN-SPAM opt-in flag.
false— the contact is in your audience but will not receive marketing messages (welcome series, promotions, re-engagement). Transactional messages (order confirmations) are not affected.true— the contact is eligible for marketing messages.
Default behavior by path:
- Form submission: consent follows the form’s
marketing_consent_field. A form with a “subscribe” checkbox sets consent from the checkbox value. A form without that field sets consent tofalse. - Direct upsert: defaults to
false. Passmarketing_consent: trueexplicitly to opt in — only do this when you have proof of consent.
To revoke consent, upsert with marketing_consent: false. Active campaigns stop sending to the contact immediately.
Segments and tags
Section titled “Segments and tags”Tags are string labels on a contact. Segments reference tags and other contact attributes:
Segment "VIP customers" WHERE marketing_consent = true AND tags CONTAINS "vip" AND created_at < NOW() - 90 daysSegment membership is evaluated continuously. A contact whose traits change can enter or exit a segment at any time, which can trigger journey enrollment or stop further steps.
Form field types
Section titled “Form field types”Forms are created in the dashboard — there is no public API for form creation. Once a form exists, submit to it with any field that matches the schema.
Supported field types: text, email, phone, number, date, datetime, checkbox, radio, select, textarea, file.
Each type validates on the server. A phone field must be a valid E.164 number; an email field must be a valid address. Mismatches return 422 validation with reason: "format".
Submission caps
Section titled “Submission caps”Two per-tier limits apply.
Forms count (distinct active forms per business):
| Tier | Forms |
|---|---|
| Starter | 3 |
| Lite | 5 |
| Growth | 10 |
| Business | 25 |
| Enterprise | Unlimited |
| Lifetime | Unlimited |
Monthly submissions (total across all forms):
| Tier | Submissions / month |
|---|---|
| Starter | 1,000 |
| Lite | 5,000 |
| Growth | 10,000 |
| Business | 20,000 |
| Enterprise | Unlimited |
| Lifetime | Unlimited |
Past the monthly cap, form.submit returns 429 submission_cap. The cap resets on the first of each month. Check current usage with caramel.v1.meta.usage.
Note There is also a per-form anti-abuse limit: 10 submissions per 60 seconds scoped to
form_id+ hashed IP. This is independent of the monthly cap.
Form lifecycle
Section titled “Form lifecycle”Forms are managed in the dashboard. The status field is read-only from the API.
| Status | Behavior on submit |
|---|---|
draft | Returns 404 form_not_found. |
active | Accepts submissions. |
paused | Returns 403 form_paused. |
archived | Returns 404 form_not_found. Historical submissions are still queryable. |
Idempotency
Section titled “Idempotency”form.submit does not deduplicate by payload. Submitting the same data twice creates 2 submission records and may re-enroll the contact in a journey. If you can’t trust the client (for example, a user double-clicking a button), include a client-generated dedup key in the payload and check for it in your handler.
A server-side Idempotency-Key header is on the roadmap.
Next steps
Section titled “Next steps”- Campaigns and journeys — what happens after a contact is enrolled
- Businesses — how tier caps are enforced
- Tools reference —
caramel.v1.form.submit,caramel.v1.contact.upsert