Contact Lists
Reusable audiences you can send to without a recipient limit.
email.bulk and templates.bulkSend cap a single request at 1000 recipients because those requests carry their recipients in the body. A list send carries only a listId: the server expands the list in the background, so the size of the audience stops being a property of the HTTP request.
const list = await outbound.contactLists.create({ name: 'Q3 Leads' });
await outbound.contactLists.importContacts(list.list.id, {
rows: [
{ email: 'ada@example.com', name: 'Ada', attributes: { company: 'Acme', plan: 'pro' } },
{ email: 'bo@example.com', name: 'Bo', attributes: { company: 'Bee', plan: 'free' } },
],
});
await outbound.email.bulk({
listId: list.list.id,
fromEmail: 'hello@yourdomain.com',
emailSubject: 'Hi {{firstName}}',
htmlBody: '<p>Hi {{firstName}} at {{company}}</p>',
mapping: { firstName: 'name', company: 'company' },
});Address hygiene
Every address you import is sanitized and then validated with the same rules the send path applies.
Sanitization is mandatory and always runs:
| Input | Stored as |
|---|---|
Ada <ADA@Example.COM> | ada@example.com |
mailto:bo@x.io | bo@x.io |
"cy@x.io", | cy@x.io |
de@x.io + a trailing zero-width space | de@x.io |
The original is kept in emailRaw whenever sanitization changed something, so you can always show a user what was corrected.
Addresses that fail validation are stored, not discarded. They arrive with status: 'invalid' and a statusReason, so you can list them, export them and fix them rather than wondering which rows vanished. The only rows an import rejects outright are ones with no address at all — there is no key to store those under.
Why the same regex
An address that passes a looser import check and fails at send time is a contact that imports cleanly and then silently never receives mail. Import validation uses the send path's own rules precisely so that cannot happen.
Contact status
Only subscribed contacts are ever sent to.
| Status | Meaning |
|---|---|
subscribed | Clean and sendable |
invalid | Failed address validation |
bounced | Hard bounce reported by the provider |
complained | Marked as spam |
unsubscribed | Opted out via the unsubscribe link |
suppressed | On your suppression list for another reason |
Status is maintained for you. A bounce, complaint, unsubscribe or manual suppression marks that address in every list you own, and a list send re-checks your suppression list per batch, so a list improves every time you send to it. Removing a suppression returns those contacts to subscribed — but never resurrects an invalid one, because a malformed address is still malformed.
Create
const { list } = await outbound.contactLists.create({
name: 'Q3 Leads',
description: 'Everyone who downloaded the Q3 report',
});Possible Errors
| Error | Status | Cause |
|---|---|---|
ConflictError | 409 | A list with this name already exists |
RateLimitError | 429 | Contact list quota exceeded |
List
const { lists, pagination, quota } = await outbound.contactLists.list({ limit: 20 });
// Or iterate every list without paging by hand:
for await (const list of outbound.contactLists.listAll()) {
console.log(list.name, list.contactCount);
}Get
Returns the list plus a per-status breakdown.
const { list, statusBreakdown, sendable, sendablePercent } =
await outbound.contactLists.get(listId);
console.log(`${sendable} of ${list.contactCount} are sendable (${sendablePercent}%)`);
console.log(statusBreakdown);
// { subscribed: 24102, invalid: 118, bounced: 62, complained: 4, unsubscribed: 14, suppressed: 0 }Import contacts
Up to 1000 rows per call. For a larger file, send it in chunks.
import { randomUUID } from 'node:crypto';
const importId = randomUUID(); // one per FILE
for (let i = 0; i < chunks.length; i++) {
await outbound.contactLists.importContacts(listId, {
importId,
chunkIndex: i,
rows: chunks[i],
onExisting: 'update',
});
}Parameters
| Field | Type | Required | Description |
|---|---|---|---|
rows | array | Yes | { email, name?, attributes? }, max 1000 |
importId | string | No | One per file. With chunkIndex, makes a retried chunk a no-op |
chunkIndex | number | No | 0-based index of this chunk |
duplicatesInFile | string | No | first (default) or last — which row wins for a repeated address |
onExisting | string | No | skip (default) or update. update merges attributes |
Re-importing never re-subscribes anyone
onExisting: 'update' merges attributes and refreshes the name, but it will not move a contact out of bounced, complained or unsubscribed. Re-uploading last quarter's CSV cannot resurrect addresses that have since gone bad.
Response
{
summary: {
requested: 1000,
added: 940, updated: 38, skipped: 6,
sanitized: 21, // addresses cleanup actually changed
invalid: 9, // STORED and marked, not discarded
suppressed: 4, // already on your suppression list
duplicatesInFile: 2,
rejected: 1 // no address at all — the only rows not stored
},
invalidSamples: [{ email: 'bad@', emailRaw: 'bad@ ', reason: 'invalid_format' }],
list: { id: '...', contactCount: 1234940, fields: ['company', 'plan'] }
}List contacts
Keyset paginated: pass the previous response's nextCursor to advance. There is no page number, because offset paging degrades badly past a few hundred pages and these lists are designed to hold millions of rows.
const page = await outbound.contactLists.contacts(listId, { status: 'invalid' });
// Or iterate everything:
for await (const contact of outbound.contactLists.contactsAll(listId)) {
console.log(contact.email, contact.status, contact.statusReason);
}total may be a floor
When you filter by status or search, pagination.total is capped and totalCapped is true — an exact count over a filtered million-row list is not something to run on every page.
Delete contacts
// By id or address (max 1000)
await outbound.contactLists.bulkDeleteContacts(listId, { contactIds: [...] });
// Or by FILTER — one small request however many rows it removes
await outbound.contactLists.bulkDeleteContacts(listId, { status: 'invalid' });
await outbound.contactLists.bulkDeleteContacts(listId, { all: true });Filtered deletes return 202 and run in the background.
Re-validate
Re-runs validation and re-syncs your suppression list across the whole list. Worth running after a bulk suppression import.
await outbound.contactLists.revalidate(listId);Fields
The column names available for send-time variable mapping.
const { fields, reservedFields } = await outbound.contactLists.fields(listId);
// fields: [{ name: 'company', sample: 'Acme' }, { name: 'plan', sample: 'pro' }]
// reservedFields: [{ name: 'email', sample: 'ada@example.com' }, { name: 'name', sample: 'Ada Lovelace' }]Renaming or removing a column
fields grows as imports introduce columns. When one is wrong — a typo'd header, or a column you no longer want — rewrite it across the whole list. Both return 202 and run as a chunked background job.
await outbound.contactLists.renameField(listId, 'Comapny', 'Company');
await outbound.contactLists.deleteField(listId, 'ScratchNotes');Renaming onto a name that already exists returns 409: merging two columns is a data decision (which value wins per contact?), not a rename. Deleting takes the values with it and is not recoverable.
Sending to a list
Both email.bulk and templates.bulkSend accept listId instead of an inline recipient array.
const result = await outbound.templates.bulkSend({
templateId,
listId,
fromEmail: 'hello@yourdomain.com',
mapping: { firstName: 'first_name', company: 'Company' },
defaults: { company: 'your company' }, // for contacts missing that column
onMissingVariable: 'skip', // or 'send' to deliver with the gap
});
// { mode: 'list', jobId, listName, estimatedRecipientCount, status: 'expanding' }mapping says which contact column feeds each {{variable}}. Omit an entry when the column is already named the same as the variable. email and name are mappable alongside the list's own fields.
Mapping mistakes are caught before the 202
Every required {{variable}} is checked against your mapping, the list's known columns and your defaults before the send is accepted. A variable nothing feeds is a 400 naming it — not a job that silently skips every recipient.
Possible Errors
| Error | Status | Cause |
|---|---|---|
ValidationError | 400 | The list is empty, has no sendable contacts, or a {{variable}} has no column |
NotFoundError | 404 | No such list |
ConflictError | 409 | This list is already being expanded for another send |
Tracking a list send
The jobId behaves like any other, with extra expansion detail:
const status = await outbound.email.status(jobId);
// { lookupType: 'listJob', job, listSend: { status, recipientsCreated,
// skipped: { suppressed, unsubscribed, invalid, missingVariables }, ... } }Contacts that are skipped still get a recipient record with the reason, so nobody disappears silently from the report.