Contacts
A contact is a subscriber in one audience. Email is unique per audience.
The Contact object
{
"id": "sub_xyz789q4v0dz",
"object": "contact",
"email": "jane@acme.com",
"first_name": "Jane",
"last_name": "Doe",
"attributes": { "company": "Acme", "plan": "pro" },
"status": "subscribed",
"source": "api",
"topics": null,
"unsubscribed_at": null,
"created_at": "2026-03-10T08:00:00.000Z",
"updated_at": "2026-07-01T10:00:00.000Z"
}Status
| Status | Writable | Meaning |
|---|---|---|
subscribed | Yes | Mailable. The default. |
unsubscribed | Yes | Opted out. Record kept, mail stopped. |
pending | No | Awaiting double opt-in confirmation. |
bounced | No | Owned by the delivery pipeline. |
complained | No | Owned by the delivery pipeline. |
suppressed | No | Owned by the delivery pipeline. |
Set subscribed or unsubscribed on create, and flip between them with
PATCH. The other four belong to delivery and double opt-in. An upsert against
a contact in one of those states updates the other fields and leaves status
alone.
You can create a contact already unsubscribed. That is how a migration carries opt-outs across without re-mailing anyone.
Addressing a contact
Anywhere a contact id goes, you can use either:
- the id:
/contacts/sub_xyz789q4v0dz - a URL-encoded email:
/contacts/jane%40acme.com
No lookup round-trip needed. Emails are trimmed and lowercased first, so
Ada@Acme.com and ada@acme.com are the same contact.
Attributes
attributes is a flat string-to-string map: up to 50 keys, values up to 500
characters. Unknown keys register themselves as fields and become
{{merge_tags}} in campaigns. Nothing to declare up front.
Values must be strings. {"orders": 5} fails validation and rejects the
entire batch with 400 invalid_request, not just that row. Stringify every
value.
email,first_nameandlast_nameare reserved. Insideattributesthey are silently ignored. Send them top-level.- Keys are normalized to
snake_case:"Company / Org"becomescompany_org. Two source columns can collide into one key. - On upsert and
PATCH,attributesis a shallow merge: provided keys overwrite, absent keys survive,nulldeletes the key.
List contacts
/v1/audiences/{audience_id}/contacts| Param | Notes |
|---|---|
status | Any status, including the read-only ones. |
email | Exact match, after canonicalization. |
segment_id | Members of a segment, evaluated live. |
limit, after | Pagination. |
curl "https://go.day3.app/api/v1/audiences/aud_123/contacts?status=subscribed&limit=100" \
-H "Authorization: Bearer $DAY3_API_KEY"Create a contact
/v1/audiences/{audience_id}/contactscurl -X POST https://go.day3.app/api/v1/audiences/aud_123/contacts \
-H "Authorization: Bearer $DAY3_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@acme.com",
"first_name": "Jane",
"attributes": { "company": "Acme", "plan": "pro" }
}'| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Canonicalized on the way in. |
first_name | string | No | |
last_name | string | No | |
attributes | object | No | Flat string to string. |
status | string | No | subscribed (default) or unsubscribed. |
unsubscribed_at | string | No | ISO-8601. Only with status: "unsubscribed". |
An address on the account’s suppression list is 409 email_suppressed and
nothing is created. That’s correct behaviour. Don’t retry.
Upsert
?upsert=true changes what happens on conflict:
| Email is new | Email exists | |
|---|---|---|
| Default | 201 created | 409 contact_already_exists |
?upsert=true | 201 created | 200 updated |
Upsert and PATCH merge: provided fields overwrite, attributes merges
shallowly.
Batch create or upsert
/v1/audiences/{audience_id}/contacts/batchUp to 1,000 contacts per call, counted as one request against the rate limit. Never loop single creates for an import.
{
"upsert": true,
"contacts": [
{ "email": "a@x.com", "first_name": "Ada" },
{ "email": "b@y.com", "status": "unsubscribed", "unsubscribed_at": "2025-11-02T10:00:00.000Z" }
]
}Each item has the single-create shape, plus an optional topics map applied
after the upsert. Top-level upsert applies to every item.
Returns 200 even when rows fail:
{
"object": "batch_result",
"summary": { "created": 940, "updated": 55, "failed": 5 },
"results": [
{ "index": 0, "status": "created", "id": "sub_..." },
{ "index": 1, "status": "updated", "id": "sub_..." },
{ "index": 2, "status": "failed", "error": { "code": "invalid_email", "message": "..." } }
]
}Per-item failures: an invalid email, a suppressed email, or a conflict when
upsert is false. The rest of the batch still applies.
Whole-request rejections apply nothing. They only happen for caller bugs:
| Cause | Response |
|---|---|
| More than 1,000 items | 400 batch_too_large |
| Duplicate emails in the payload | 400 invalid_request, offending indexes listed |
| A non-string attribute value | 400 invalid_request |
| An unknown topic id | 400 invalid_request |
| Free-plan subscriber cap would be crossed | 403 plan_limit_reached |
Duplicates are checked after canonicalization: Ada@Acme.com and
ada@acme.com in one payload reject the whole request. De-duplicate
case-insensitively before you send.
Always send an Idempotency-Key on a batch, so a network failure is safe to
retry.
Retrieve a contact
/v1/audiences/{audience_id}/contacts/{id_or_email}?expand=topics fills topics with the effective per-topic map instead of
null.
curl "https://go.day3.app/api/v1/audiences/aud_123/contacts/jane%40acme.com?expand=topics" \
-H "Authorization: Bearer $DAY3_API_KEY"Update a contact
/v1/audiences/{audience_id}/contacts/{id_or_email}Same fields and merge rules as create. status only flips between
subscribed and unsubscribed.
Delete a contact
/v1/audiences/{audience_id}/contacts/{id_or_email}DELETE erases the row. It is the GDPR erasure path. To stop mailing
someone and keep the record, PATCH { "status": "unsubscribed" }.
Contact topics
/v1/audiences/{audience_id}/contacts/{id_or_email}/topicsReturns the effective state: explicit choices overlaid on topic defaults.
is_default says which one a value came from.
{
"data": [
{ "topic_id": "top_789", "name": "Product updates", "subscribed": false, "is_default": false },
{ "topic_id": "top_790", "name": "Promotions", "subscribed": true, "is_default": true }
]
}/v1/audiences/{audience_id}/contacts/{id_or_email}/topicsTakes a partial map and records explicit choices. Topics you leave out are untouched.
{ "topics": { "top_789": false, "top_790": true } }Use this to carry Mailchimp groups and interests across, per contact, or in bulk
with the topics key on batch items.
Gotchas
- You can’t backdate a contact.
created_atin a payload is silently dropped.unsubscribed_atis the one exception, honoured on create. - Unknown top-level fields are dropped, not rejected. A payload can look accepted and still lose data. Map every source column.
- Suppression wins. Import suppressions first and contacts for those addresses fail on the way in. That’s the point.
More in Conventions.