Conventions
These apply to every endpoint and aren’t repeated on resource pages. Read them once before you write an import script.
Requests and responses
JSON in, JSON out, UTF-8. Send Content-Type: application/json with any body.
- Fields are
snake_case. - Ids are prefixed strings:
eml_,aud_,sub_,fld_,seg_,top_,cmp_,aut_,aen_,sup_,whe_,whd_,evt_. Treat them as opaque. - Timestamps are ISO-8601 UTC with milliseconds:
2026-08-21T09:00:00.000Z. - Ignore unknown response fields. Additive changes ship in
v1without a version bump. Breaking changes would bev2. - Deletes return
200with{ "id": "...", "deleted": true }, not204. - Another organization’s id is
404, never403. Existence never leaks.
Unknown fields in a request are dropped, not rejected
An unrecognised top-level field is silently ignored. A payload can look accepted while losing data.
You can’t backdate a contact. created_at in a payload is stripped and the
contact gets today’s date. Original signup dates can’t be preserved on import.
unsubscribed_at is the exception: it is honoured on create, so opt-out dates
survive a migration.
Map every source column to a documented field or into attributes, and tell
your user what couldn’t be carried over.
Pagination
Every list endpoint is cursor-paginated. No page numbers, no offsets: offsets break under concurrent writes, which is exactly the migration case.
GET /audiences/aud_123/contacts?limit=100&after=sub_01h...| Param | Values |
|---|---|
limit | 1 to 100. Default 50. |
after | Opaque cursor. Pass the previous response’s next_cursor. |
{
"data": ["..."],
"has_more": true,
"next_cursor": "sub_01h..."
}Loop until has_more is false. Order is stable, newest first
(created_at DESC, id DESC), and the cursor encodes both.
let after;
do {
const url = new URL("https://go.day3.app/api/v1/audiences/aud_123/contacts");
url.searchParams.set("limit", "100");
if (after) url.searchParams.set("after", after);
const res = await fetch(url, {
headers: { Authorization: "Bearer " + process.env.DAY3_API_KEY },
});
const page = await res.json();
handle(page.data);
after = page.next_cursor;
} while (after);Idempotency
Every POST accepts:
Idempotency-Key: <your own string, 255 characters or fewer>A retry with the same key within 24 hours replays the original response, same
status and body, with Idempotency-Replayed: true. Always set it on imports and
sends.
- Same key, different body:
409 idempotency_conflict. - Two concurrent requests with one key run exactly once. The loser gets
409 idempotency_conflict(“already in progress”) and should retry shortly. - A claim abandoned by a crashed request is taken over after five minutes.
POST /emailscompletes its claim in the same transaction that writes the email. A crash leaves either no email (the retry sends it) or an email whose response the retry replays. Never a duplicate send.
PATCH and DELETE are naturally idempotent and need no header.
Rate limits
600 requests per minute per organization, sliding window.
| Header | On |
|---|---|
RateLimit-Limit | Every response |
RateLimit-Remaining | Every response |
RateLimit-Reset | Every response |
Retry-After | 429 only, in seconds |
On 429 rate_limit_exceeded, sleep Retry-After seconds and retry. Don’t back
off blindly.
Transactional sends have their own bucket inside that limit: 120 per minute per organization by default, so a burst of password resets can’t starve the rest of the API.
A batch call counts as one request. Importing 50,000 contacts is 50 requests, not 50,000. Never loop single creates for an import. See Contacts.
Plan limits
The API enforces the same rules as the app.
| Situation | Response |
|---|---|
| Free plan, list would exceed 500 subscribers | 403 plan_limit_reached |
| Monthly send allowance exhausted | 403 plan_limit_reached |
| Free plan, recipient outside your organization | 403 sandbox_recipient_not_allowed |
| Sending disabled on the account | 403 sending_disabled |
A batch that would cross the subscriber cap is rejected whole, never partially applied, and the message states the remaining headroom. Re-run the import after upgrading. Don’t retry it, and don’t split the batch to sneak under the cap.