Skip to content

Contacts and consent

Contacts are stored in the contacts table in your own account. Each one has an email address, a status, custom fields, tags, a consent record and an engagement score. The examples use $BASE and $KEY from the quick start.

Status How a contact gets it Can you send to it?
subscribed You add it with a consent record, or an import brings it in Yes
unsubscribed Their unsubscribe link, one-click unsubscribe, the API, or an import No, until you resubscribe it with new consent
bounced A hard bounce, or 3 soft bounces in a row No
complained They marked an email as spam No
cleaned Mailchimp had cleaned it No

A status only ever moves to a more restrictive one by itself. Bounced, complained and cleaned contacts can’t be resubscribed, which protects your sender reputation.

Every subscribed contact needs a consent record: where they agreed (source), when (at), how (method: single_opt_in, double_opt_in, attested or imported), and optionally the ip they agreed from. Imports fill it in from Mailchimp’s opt-in data, or from your CSV columns or attestation.

POST /contacts creates a contact, or updates the one with that address.

Terminal window
curl -X POST "$BASE/contacts" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"email": "grace.hopper@example.com",
"fields": { "FNAME": "Grace", "plan": "team" },
"tags": ["customer"],
"consent": { "source": "signup-form", "at": "2026-09-01T10:00:00Z", "method": "single_opt_in", "ip": "203.0.113.7" }
}'

It returns 201 with the new contact, or 200 if the address already existed. Addresses are trimmed and lower-cased. A subscribed contact without a consent record gives 422 consent_required.

Check it worked: the response has an id starting ct_ and "status": "subscribed".

Email addresses never go in URLs, so look them up with a POST:

Terminal window
curl -X POST "$BASE/contacts/lookup" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"email":"grace.hopper@example.com"}'

data holds the contact, or is empty if there’s no contact with that address.

Send the version you read in If-Match, so you never overwrite a change you haven’t seen. Fields merge into the existing ones, and a null value removes a field.

Terminal window
curl -X PATCH "$BASE/contacts/<ct_...>" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-H 'If-Match: 1' -d '{"fields":{"plan":"enterprise"},"addTags":["beta"]}'

Each change raises the version. To remove a tag, send the new version:

Terminal window
curl -X PATCH "$BASE/contacts/<ct_...>" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-H 'If-Match: 2' -d '{"removeTags":["customer"]}'

A stale version gives 412 version_conflict: read the contact again and retry.

Check it worked: the response shows "plan": "enterprise", the beta tag but not customer, and version 3.

POST /contacts/batch takes up to 1,000 contacts and returns a result for each, with its own status.

Terminal window
curl -X POST "$BASE/contacts/batch" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' -d '{
"contacts": [
{ "email": "alan@example.com", "fields": { "FNAME": "Alan" },
"consent": { "source": "checkout", "at": "2026-08-20T08:00:00Z", "method": "single_opt_in" } },
{ "email": "katherine@example.com", "status": "unsubscribed" }
]
}'

The response is 207 with results: one entry per contact, in order, each with status 201, 200 or an error.

Terminal window
curl "$BASE/contacts?status=subscribed&tag=beta&limit=50" -H "Authorization: Bearer $KEY"

Filter by status and one tag. See Pagination for nextCursor.

Unsubscribe a contact who asked you another way, for example by phone:

Terminal window
curl -X POST "$BASE/contacts/<ct_...>/unsubscribe" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"reason":"asked by phone"}'

Adding the address again as subscribed doesn’t undo it:

Terminal window
# Expect 409 contact_suppressed
curl -X POST "$BASE/contacts" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"email":"grace.hopper@example.com","consent":{"source":"signup-form","at":"2026-09-02T10:00:00Z","method":"single_opt_in"}}'

To subscribe them again, resubscribe the contact with a new consent record:

Terminal window
curl -X POST "$BASE/contacts/<ct_...>/resubscribe" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"consent":{"source":"phone","at":"2026-09-10T15:30:00Z","method":"attested"}}'

Check it worked: the contact shows "status": "subscribed" and the new consent record.

Terminal window
curl "$BASE/contacts/<ct_...>" -H "Authorization: Bearer $KEY"

Every unsubscribe publishes a contact.unsubscribed event to your EventBridge bus.

Each contact has a score from 0 to 100: a human open adds 3, a human click adds 10, a machine open adds 0.5, and the score halves every 90 days without activity. Imported contacts start from their Mailchimp rating. Stage plans send to the highest scores first.

Sunsetting leaves long-unengaged contacts out of your campaigns. It’s off by default. Turn it on in your settings:

Terminal window
curl -X PATCH "$BASE/settings" -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
-d '{"sunset":{"enabled":true}}'

With it on, a contact that has received at least 4 emails with no human open or click in 180 days gets sunsetAt set by our daily job, and segments leave it out unless they set includeSunset. A click clears it. You can change both numbers in settings.

Bot opens and link-scanner clicks are filtered either way: they never count as human opens or clicks, in the score or in reports.

To erase a contact completely, see Erasure and export.