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.
Statuses
Section titled “Statuses”| 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.
Consent records
Section titled “Consent records”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.
Add a contact
Section titled “Add a contact”POST /contacts creates a contact, or updates the one with that address.
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".
Look a contact up by email
Section titled “Look a contact up by email”Email addresses never go in URLs, so look them up with a POST:
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.
Update fields and tags
Section titled “Update fields and tags”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.
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:
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.
Add many contacts at once
Section titled “Add many contacts at once”POST /contacts/batch takes up to 1,000 contacts and returns a result for each, with its own status.
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.
List contacts
Section titled “List contacts”curl "$BASE/contacts?status=subscribed&tag=beta&limit=50" -H "Authorization: Bearer $KEY"Filter by status and one tag. See Pagination for nextCursor.
Unsubscribe and resubscribe
Section titled “Unsubscribe and resubscribe”Unsubscribe a contact who asked you another way, for example by phone:
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:
# Expect 409 contact_suppressedcurl -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:
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.
curl "$BASE/contacts/<ct_...>" -H "Authorization: Bearer $KEY"Every unsubscribe publishes a contact.unsubscribed event to your EventBridge bus.
Engagement score and sunset
Section titled “Engagement score and sunset”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:
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.