Skip to content

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.

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"
}
FieldDescription
emailPrimary key within a business. Required on every contact.
first_name, last_nameUsed by templates for personalization.
localePreferred message language. Inherits from the business if not set.
phoneRequired for SMS or WhatsApp delivery.
marketing_consentGDPR/CAN-SPAM opt-in flag. false blocks marketing messages (not transactionals).
sourceHow the contact entered the audience. Set on creation; does not change on subsequent updates.
tagsFree-form labels. Used by segment definitions.

Caramel always lowercases emails server-side. Jane@Example.com matches an existing jane@example.com.

There are 3 paths:

  1. Form submission (caramel.v1.form.submit) — the most common. A submission upserts the contact based on the form’s field schema.
  2. 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 the audience:write scope and Business tier or above.
  3. 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.

Terminal window
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 httpx
res = 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'},
},
)
  1. Validate. Every required field is present; every typed field matches its type.
  2. Upsert the contact. Email is the key. Existing contacts are updated; new contacts are created.
  3. Apply marketing consent. If the form has a marketing_consent_field, the checkbox value is written to the contact.
  4. Record the submission. Stored with source_url, utm_params, hashed IP, and timestamp. Counts toward the monthly submission cap.
  5. Evaluate segments. The contact’s segment memberships are re-evaluated immediately.
  6. 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.
  7. Send notification (optional). If notification_email is set on the form, a submission summary email is sent to that address.
{
"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.

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 codeMeaning
requiredField is required but missing or empty.
formatType validation failed (e.g. invalid email, non-numeric value in a number field).
lengthString length below the minimum or above the maximum.
patternValue doesn’t match a regex constraint.
enumValue is not one of the allowed radio or select options.
consent_requiredA 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 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 to false.
  • Direct upsert: defaults to false. Pass marketing_consent: true explicitly 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.

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 days

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

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

Two per-tier limits apply.

Forms count (distinct active forms per business):

TierForms
Starter3
Lite5
Growth10
Business25
EnterpriseUnlimited
LifetimeUnlimited

Monthly submissions (total across all forms):

TierSubmissions / month
Starter1,000
Lite5,000
Growth10,000
Business20,000
EnterpriseUnlimited
LifetimeUnlimited

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.

Forms are managed in the dashboard. The status field is read-only from the API.

StatusBehavior on submit
draftReturns 404 form_not_found.
activeAccepts submissions.
pausedReturns 403 form_paused.
archivedReturns 404 form_not_found. Historical submissions are still queryable.

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.