Contacts
A contact is a subscriber inside one audience. Email is unique per audience.
Internally these rows are called subscribers. The API says contacts, matching the app’s Contacts tab.
This is the resource most worth reading carefully, because it is where a migration either goes smoothly or silently loses data.
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. Keep the record, stop the mail. |
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. |
You may set subscribed or unsubscribed on create, and flip between the two on
PATCH. The other four belong to the delivery pipeline and the double opt-in
flow. An upsert against a contact in one of those states updates the other
fields and leaves the status alone.
Being able to create a contact already unsubscribed is deliberate: a migrating sender has to carry opt-outs across and must never re-mail them.
Addressing a contact
Anywhere a contact id appears you can use either:
- the id:
/contacts/sub_xyz789q4v0dz - a URL-encoded email:
/contacts/jane%40acme.com
Email is unique per audience, so this is unambiguous, and it removes the “look up the id first” round-trip that makes migrations painful.
Emails are canonicalized, meaning trimmed and lowercased, before anything else.
Ada@Acme.com and ada@acme.com are the same contact.
Attributes
attributes is a flat map of string to string. Up to 50 keys, values up to 500
characters. Unknown keys register themselves as fields, so there
is nothing to declare up front, and they become {{merge_tags}} in campaigns.
Four things bite when importing a provider export:
Values must be strings. A number or boolean, such as {"orders": 5}, fails
validation and rejects the entire batch with 400 invalid_request, not just
that row. Stringify every value before sending.
email,first_nameandlast_nameare reserved. They are real columns, so putting them inattributesis silently ignored. Send them as top-level fields.- Keys are normalized to
snake_case, so"Company / Org"becomescompany_org. Two source columns can therefore collide into one key. - On upsert,
attributesis a shallow merge: provided keys overwrite, absent keys survive, and an explicitnullvalue deletes that key.
List contacts
/v1/audiences/{audience_id}/contacts| Param | Notes |
|---|---|
status | Filter by any status, including the read-only ones. |
email | Exact match, after canonicalization. |
segment_id | Members of a segment, evaluated live. |
limit, after | Standard 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}/contacts| 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". |
curl -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" }
}'Upsert
Add ?upsert=true to change what happens on conflict:
| Email is new | Email exists | |
|---|---|---|
| Default | 201 created | 409 contact_already_exists |
?upsert=true | 201 created | 200 updated |
Merge semantics on both upsert and PATCH: provided fields overwrite, and
attributes is a shallow merge.
An email on the account’s suppression list is 409 email_suppressed, and the
contact is not created. That is correct behaviour, not an error to retry.
Batch create or upsert
/v1/audiences/{audience_id}/contacts/batchThe migration workhorse. Up to 1,000 contacts per call, and the whole call costs 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 takes the same shape as a single create, plus an optional
topics map applied after the upsert. Top-level upsert applies to every item.
Response is 200 even when individual 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
Invalid email, suppressed email, or a conflict when upsert is false. Everything
else in the batch still applies.
Whole-request rejections
The batch is rejected entirely, with nothing applied, only for caller bugs:
| Cause | Response |
|---|---|
| More than 1,000 items | 400 batch_too_large |
| Duplicate emails within the payload | 400 invalid_request, offending indexes listed |
| A non-string attribute value | 400 invalid_request |
| An unknown topic id | 400 invalid_request |
| Free-tier subscriber cap would be crossed | 403 plan_limit_reached |
Because emails are canonicalized first, Ada@Acme.com and ada@acme.com in one
payload are a duplicate and reject the whole request. De-duplicate
case-insensitively before sending a chunk.
Always send Idempotency-Key on a batch. A network-failed import can then be
retried safely, which together with per-row results is what makes a migration a
short loop rather than a project.
Retrieve a contact
/v1/audiences/{audience_id}/contacts/{id_or_email}Add ?expand=topics to populate topics with the effective per-topic
subscription 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 as create, same merge semantics. status may only flip between
subscribed and unsubscribed.
Delete a contact
/v1/audiences/{audience_id}/contacts/{id_or_email}DELETE erases the row. This is the GDPR erasure path. To stop mailing
someone while keeping the record, PATCH with { "status": "unsubscribed" }
instead.
Contact topics
/v1/audiences/{audience_id}/contacts/{id_or_email}/topicsReturns the effective state, with explicit rows overlaid on topic defaults:
{
"data": [
{ "topic_id": "top_789", "name": "Product updates", "subscribed": false, "is_default": false },
{ "topic_id": "top_790", "name": "Promotions", "subscribed": true, "is_default": true }
]
}is_default tells you whether the value comes from the topic’s default or from
an explicit choice this contact made.
/v1/audiences/{audience_id}/contacts/{id_or_email}/topicsTakes a partial map and records explicit deviations. Topics you leave out are untouched.
{ "topics": { "top_789": false, "top_790": true } }This is how a Mailchimp migration carries group and interest opt-ins across, one
call per contact, or in bulk via the topics key on each batch item.
Things that will surprise you
- You cannot backdate a contact.
created_atin a payload is silently dropped.unsubscribed_atis the one exception and is honoured on create. - Unknown top-level fields are dropped, not rejected, so a payload can look accepted while losing data. Map every source column deliberately.
- Suppression beats everything. If you import a suppression list first, contacts for those addresses will fail on the way in. That is the point.
- See Conventions for the full list.