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.
- Duplicate emails in one payload reject the whole request. Emails are
lowercased first, so
Ada@Acme.comandada@acme.comcollide. De-duplicate case-insensitively per chunk. - You cannot backdate a contact.
created_atis silently dropped; original signup dates are lost.unsubscribed_atis honoured, so opt-out dates survive. email,first_nameandlast_nameare ignored insideattributes. They are real columns. Send them top-level.- Attribute keys are normalized to
snake_case."Company / Org"becomescompany_org, so two source columns can collide. Check the mapping before running 50 chunks. - 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.combefore, 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 .