# Forms and audience

> How form submissions upsert contacts, apply marketing consent, and trigger segment enrollment and journey entry.

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

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.

```json
{
  "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

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.

## Submit to a form

```bash
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" }
  }'
```

```js
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();
```

```py
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'},
    },
)
```

### What happens on the server

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.

### Response

```json
{
  "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

A submission with missing or invalid fields returns `422 validation`:

```json
{
  "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

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

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

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

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

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

`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

- [Campaigns and journeys](campaigns-and-journeys) — what happens after a contact is enrolled
- [Businesses](businesses) — how tier caps are enforced
- [Tools reference](../reference/tools) — `caramel.v1.form.submit`, `caramel.v1.contact.upsert`
