Skip to Content
Contacts

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

StatusWritableMeaning
subscribedYesMailable. The default.
unsubscribedYesOpted out. Record kept, mail stopped.
pendingNoAwaiting double opt-in confirmation.
bouncedNoOwned by the delivery pipeline.
complainedNoOwned by the delivery pipeline.
suppressedNoOwned 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_name and last_name are reserved. Inside attributes they are silently ignored. Send them top-level.
  • Keys are normalized to snake_case: "Company / Org" becomes company_org. Two source columns can collide into one key.
  • On upsert and PATCH, attributes is a shallow merge: provided keys overwrite, absent keys survive, null deletes the key.

List contacts

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

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 newEmail exists
Default201 created409 contact_already_exists
?upsert=true201 created200 updated

Upsert and PATCH merge: provided fields overwrite, attributes merges shallowly.

Batch create or upsert

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

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

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

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

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

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

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

Returns 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 } ] }
PATCH/v1/audiences/{audience_id}/contacts/{id_or_email}/topics

Takes 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

  1. You can’t backdate a contact. created_at in a payload is silently dropped. unsubscribed_at is the one exception, honoured on create.
  2. Unknown top-level fields are dropped, not rejected. A payload can look accepted and still lose data. Map every source column.
  3. Suppression wins. Import suppressions first and contacts for those addresses fail on the way in. That’s the point.

More in Conventions.

Last updated on