Skip to content

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.

ts
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:

InputStored as
Ada <ADA@Example.COM>ada@example.com
mailto:bo@x.iobo@x.io
"cy@x.io",cy@x.io
de@x.io + a trailing zero-width spacede@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.

StatusMeaning
subscribedClean and sendable
invalidFailed address validation
bouncedHard bounce reported by the provider
complainedMarked as spam
unsubscribedOpted out via the unsubscribe link
suppressedOn 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

ts
const { list } = await outbound.contactLists.create({
  name: 'Q3 Leads',
  description: 'Everyone who downloaded the Q3 report',
});

Possible Errors

ErrorStatusCause
ConflictError409A list with this name already exists
RateLimitError429Contact list quota exceeded

List

ts
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.

ts
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.

ts
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

FieldTypeRequiredDescription
rowsarrayYes{ email, name?, attributes? }, max 1000
importIdstringNoOne per file. With chunkIndex, makes a retried chunk a no-op
chunkIndexnumberNo0-based index of this chunk
duplicatesInFilestringNofirst (default) or last — which row wins for a repeated address
onExistingstringNoskip (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

ts
{
  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.

ts
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

ts
// 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.

ts
await outbound.contactLists.revalidate(listId);

Fields

The column names available for send-time variable mapping.

ts
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.

ts
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.

ts
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

ErrorStatusCause
ValidationError400The list is empty, has no sendable contacts, or a {{variable}} has no column
NotFoundError404No such list
ConflictError409This list is already being expanded for another send

Tracking a list send

The jobId behaves like any other, with extra expansion detail:

ts
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.

Released under the MIT License.