Skip to Content
Contacts

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

StatusWritableMeaning
subscribedYesMailable. The default.
unsubscribedYesOpted out. Keep the record, stop the mail.
pendingNoAwaiting double opt-in confirmation.
bouncedNoOwned by the delivery pipeline.
complainedNoOwned by the delivery pipeline.
suppressedNoOwned 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_name and last_name are reserved. They are real columns, so putting them in attributes is silently ignored. Send them as top-level fields.
  • Keys are normalized to snake_case, so "Company / Org" becomes company_org. Two source columns can therefore collide into one key.
  • On upsert, attributes is a shallow merge: provided keys overwrite, absent keys survive, and an explicit null value deletes that key.

List contacts

GET/v1/audiences/{audience_id}/contacts
ParamNotes
statusFilter by any status, including the read-only ones.
emailExact match, after canonicalization.
segment_idMembers of a segment, evaluated live.
limit, afterStandard 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

POST/v1/audiences/{audience_id}/contacts
FieldTypeRequiredNotes
emailstringYesCanonicalized on the way in.
first_namestringNo
last_namestringNo
attributesobjectNoFlat string to string.
statusstringNosubscribed (default) or unsubscribed.
unsubscribed_atstringNoISO-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 newEmail exists
Default201 created409 contact_already_exists
?upsert=true201 created200 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

POST/v1/audiences/{audience_id}/contacts/batch

The 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:

CauseResponse
More than 1,000 items400 batch_too_large
Duplicate emails within the payload400 invalid_request, offending indexes listed
A non-string attribute value400 invalid_request
An unknown topic id400 invalid_request
Free-tier subscriber cap would be crossed403 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

GET/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

PATCH/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

DELETE/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

GET/v1/audiences/{audience_id}/contacts/{id_or_email}/topics

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

PATCH/v1/audiences/{audience_id}/contacts/{id_or_email}/topics

Takes 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

  1. You cannot backdate a contact. created_at in a payload is silently dropped. unsubscribed_at is the one exception and is honoured on create.
  2. Unknown top-level fields are dropped, not rejected, so a payload can look accepted while losing data. Map every source column deliberately.
  3. Suppression beats everything. If you import a suppression list first, contacts for those addresses will fail on the way in. That is the point.
  4. See Conventions for the full list.
Last updated on