Skip to Content
Conventions

Conventions

These apply to every endpoint and are not repeated on the resource pages. Read this once before writing an import script.

Requests and responses

JSON in, JSON out, UTF-8. Send Content-Type: application/json on any request with a body.

  • Field names are snake_case. The app is camelCase internally; the public API is not.
  • 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 and a Z: 2026-08-21T09:00:00.000Z.
  • Ignore unknown response fields. New fields get added without a version bump. Additive changes are not breaking and land in v1; anything breaking would be v2.
  • Deletes return 200 with { "id": "...", "deleted": true }, not 204. A body confirms what was deleted and is friendlier to naive HTTP clients.
  • Another organization’s id returns 404, never 403. Existence is never leaked.

Unknown fields in a request are dropped, not rejected

This one bites. An unrecognised top-level field is silently ignored, so a payload can look accepted while quietly losing data.

The case that catches everyone: you cannot backdate a contact. A created_at in the payload is stripped and the contact gets today’s date. There is no way to preserve original signup dates on import.

unsubscribed_at is the one exception. It is honoured on create, so opt-out dates do survive a migration.

Map every source column onto a documented field or into attributes, and tell your user what could not be carried across rather than assuming it was.

Pagination

Every list endpoint is cursor-paginated. There are no page numbers and no offsets, because offsets break under concurrent writes, which is exactly the migration case.

GET /audiences/aud_123/contacts?limit=100&after=sub_01h...
ParamValues
limit1 to 100. Default 50.
afterAn opaque cursor. Pass the previous response’s next_cursor.
{ "data": ["..."], "has_more": true, "next_cursor": "sub_01h..." }

Loop until has_more is false. Ordering 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

Any POST accepts an optional header:

Idempotency-Key: <your own string, 255 characters or fewer>

A retry with the same key within 24 hours returns the stored original response, same status and same body, with Idempotency-Replayed: true. Always set it on imports and on sends.

  • The same key with a different body is 409 idempotency_conflict.
  • Two concurrent requests with one key resolve to exactly one execution. The loser also gets 409 idempotency_conflict, meaning “already in progress”, and should retry in a moment.
  • A claim abandoned by a crashed request is taken over after five minutes.
  • POST /emails completes its claim in the same transaction that writes the email row. A crash mid-request therefore leaves either no email, so 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, as a sliding window.

HeaderOn
RateLimit-LimitEvery response
RateLimit-RemainingEvery response
RateLimit-ResetEvery response
Retry-After429 only, in seconds

On 429 rate_limit_exceeded, sleep for Retry-After seconds and retry. Do not back off blindly.

Transactional sending has its own bucket inside that limit, 120 per minute per organization by default, so a burst of password resets cannot starve the rest of the API.

A batch call counts as one request. That is the entire point of it: importing 50,000 contacts is 50 requests, not 50,000. Never loop single creates for an import. See Contacts.

Plan limits

The API enforces exactly the same rules as the app.

SituationResponse
Free plan, list would exceed 500 subscribers403 plan_limit_reached
Monthly send allowance exhausted403 plan_limit_reached
Free plan, recipient outside your organization403 sandbox_recipient_not_allowed
Sending disabled on the account403 sending_disabled

A batch that would cross the subscriber cap is rejected whole, never partially applied, with the remaining headroom in the message. So the import can simply be re-run after upgrading. Do not retry it, and do not split the batch to sneak under the cap.

Last updated on