Skip to Content
GuidesMigrate a list

Migrate a list

Moving a list is mostly about not damaging your sending reputation on the way. The order matters more than the code.

Suppressions before contacts, always. Send before the suppression list lands and your first campaign re-mails every address that already bounced or complained elsewhere. Recovering from that takes weeks.

Export from your old provider

Contacts and the suppression list. The suppression list is usually a separate download.

Check the plan limit

Free plans cap the list at 500 subscribers. A batch that would cross the cap is rejected whole, with the remaining headroom in the message.

Count your source rows. If they exceed the cap, upgrade before you start. Do not split batches to sneak under it.

Import suppressions

curl -X POST https://go.day3.app/api/v1/suppressions \ -H "Authorization: Bearer $DAY3_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: suppressions-chunk-1" \ -d '{ "reason": "bounced", "emails": ["a@x.com", "b@y.com"] }'

Up to 1,000 addresses per call. reason is required, so post each list separately: bounces as bounced, complaints as complained, global unsubscribes as unsubscribed.

Check total_suppressed_after against what you expected. The wrong file here makes your whole audience unmailable, and there is no bulk undo. See Suppressions.

Import contacts in batches

Up to 1,000 per call, and each call counts as one request against the rate limit. 50,000 contacts is 50 requests. See Contacts.

const CHUNK = 1000; for (let i = 0; i < rows.length; i += CHUNK) { const chunk = rows.slice(i, i + CHUNK); const res = await fetch( "https://go.day3.app/api/v1/audiences/aud_123/contacts/batch", { method: "POST", headers: { Authorization: "Bearer " + process.env.DAY3_API_KEY, "Content-Type": "application/json", // Deterministic, so a retry replays instead of double-writing. "Idempotency-Key": "import-v1-chunk-" + i, }, body: JSON.stringify({ upsert: true, contacts: chunk.map((r) => ({ email: r.email, first_name: r.first_name || undefined, last_name: r.last_name || undefined, // Opt-out state carries across. This is the whole point. status: r.unsubscribed ? "unsubscribed" : "subscribed", unsubscribed_at: r.unsubscribed_at || undefined, // Every value must be a string. attributes: Object.fromEntries( Object.entries(r.custom).map(([k, v]) => [k, String(v)]), ), })), }), }, ); const result = await res.json(); console.log(result.summary); // { created, updated, failed } for (const row of result.results) { if (row.status === "failed") console.warn(i + row.index, row.error.code); } }

Contacts for suppressed addresses fail with email_suppressed. That is the system working. Do not retry them.

Verify your sending domain

In the app, under Sending.

Send to your most engaged segment first

Not the whole list. See The first send.

What goes wrong on import

One non-string attribute value rejects the entire batch. A single {"orders": 5} returns 400 invalid_request for all 1,000 contacts. String() every value.

  1. Duplicate emails in one payload reject the whole request. Emails are lowercased first, so Ada@Acme.com and ada@acme.com collide. De-duplicate case-insensitively per chunk.
  2. You cannot backdate a contact. created_at is silently dropped; original signup dates are lost. unsubscribed_at is honoured, so opt-out dates survive.
  3. email, first_name and last_name are ignored inside attributes. They are real columns. Send them top-level.
  4. Attribute keys are normalized to snake_case. "Company / Org" becomes company_org, so two source columns can collide. Check the mapping before running 50 chunks.
  5. Unknown top-level fields are dropped, not rejected. Map every source column on purpose, and report what could not be carried across.

Custom fields need no declaring. Unknown attribute keys register themselves and become {{merge_tags}}. Create fields up front only to set a label, type or fallback.

Topics. Carry over groups or interests with a topics map on each batch item, using topic ids you created first. See Topics.

The first send

Reputation is built, not transferred. A new domain that mails 50,000 people on day one looks like a spammer, whatever the list’s history.

  • Keep the same domain if you can. If you sent from updates.acme.com before, keep it. Reputation follows the domain; this is the biggest lever.
  • Start with your most engaged segment: people who opened something in the last 90 days. Build a segment for it.
  • Widen over a few sends, watching bounces and complaints.

Set up webhooks first, so bounces reach your own database.

Verify the result

# Contact counts by status. curl https://go.day3.app/api/v1/audiences/aud_123 \ -H "Authorization: Bearer $DAY3_API_KEY" # Spot-check one contact, by plain email, no id lookup needed. curl "https://go.day3.app/api/v1/audiences/aud_123/contacts/jane%40acme.com?expand=topics" \ -H "Authorization: Bearer $DAY3_API_KEY" # Confirm the suppression list landed. curl "https://go.day3.app/api/v1/suppressions?limit=1" \ -H "Authorization: Bearer $DAY3_API_KEY"

contact_counts on the audience should match your source, minus rows that failed on suppressed addresses. Each batch’s results array names every failed index and why.

Further reading

The deliverability side, at length: How to migrate an email list without wrecking your deliverability .

Last updated on