Silent bulk import
Add an existing list to your audience without the signup pipeline. No verification email, no welcome email, no automation enrollment, and one event when the whole run finishes.
Add an existing list to your audience without the signup pipeline, the same operation as the
dashboard's CSV upload, callable from code. It reuses that exact pipeline underneath
(ProcessAudienceImport → chunked loader → finalize), so behavior matches the dashboard exactly:
deduplication against the audience, referral graph stitching, custom fields, and a downloadable
report of anything rejected.
This is silent, not a signup. Nobody on the list receives an email. There is no
signup.created event, and none of these contacts enroll in an automation. When the whole run
finishes, one audience.imported event fires, never a per-row event.
Scope#
Both verbs sit under imports:write, there is no separate read scope. A key that may bulk-write
the audience this way may also poll what it just queued.
Create an import#
POST /v1/imports
Queues the import and returns immediately. status starts at processing; poll
GET /v1/imports/{import} for the outcome.
| Parameter | Type | Required | Description |
|---|---|---|---|
project |
string | Yes | The prj_ handle. |
contacts |
array | Yes | 1 to 10,000 rows. See the contact object. |
duplicates |
enum | No | skip (default) leaves a matching existing contact untouched. update merges the row onto them. |
status |
enum | No | subscribed (default) attests that these people asked to hear from you. unconfirmed stores the rows without that attestation. |
update_subscription |
boolean | No | With duplicates: update, also move an existing contact's subscription to match a row's consent. Default false. See Subscription in a file. |
tags |
array | No | Up to 20 tag names. Created in the project by name if they do not already exist. |
The contact input#
Every row needs an identity: email, phone, or identifier + identifier_type (email or
phone, the importer has no other identity kinds, so identifier_type: username is refused).
| Field | Description |
|---|---|
email |
Canonicalized exactly like a real signup (Gmail dots/+tags collapse to the same identity). |
phone |
Kept as the identity when there is no email; otherwise stored as a custom field alongside an email identity. |
identifier / identifier_type |
An alternative to email/phone for a caller already carrying the generic identity axis. |
name |
Full name. |
stage |
A stage key from this project's own pipeline. Blank lands on the entry stage. The invited stage is refused, it needs a real invitation code this path cannot mint; import the row, then invite it with PATCH /v1/contacts/{id}. |
consent |
This row's own answer: subscribed, unconfirmed or unsubscribed. Wins over status for this row. Blank follows status. |
attributes |
An object of custom fields, up to 50 keys. Anything you send that is not one of the fields above lands here. |
Subscription in a file#
status is your answer for the whole list. A row can carry its own consent instead, which wins for
that row:
consent |
What the new contact becomes |
|---|---|
subscribed |
Subscribed, with consent imported and consented_by naming the teammate behind the key. |
unconfirmed |
Not subscribed (pending). Nothing is mailed to them, and they stay that way until you subscribe them. |
unsubscribed |
Unsubscribed by you (unsubscribed_by: owner), with their address on your do-not-mail list, so no broadcast or automation reaches them. |
People who already exist are left as they are, including their subscription, even with
duplicates: update. To let a file change it, also send update_subscription: true. Then, for rows
that have a consent:
subscribedsubscribes a contact who is not subscribed, and lifts an unsubscribe a teammate made.unsubscribedunsubscribes them.unconfirmeddoes nothing. A file never moves someone back to not subscribed.- A person who unsubscribed themselves, marked an email as spam, bounced or is blocked stays as they
are. They are counted in
subscription_kept_count.
Check an import#
GET /v1/imports/{import}
Returns the current counts and, once completed, up to 100 rejected rows parsed as
{ row, identifier, reason } plus a report_url: a signed link to the full reject CSV, valid for
15 minutes. row is the row's 1-based position in the contacts array you sent.
{
"object": "import",
"id": "imp_9phm4rtx2kqd",
"project": "prj_7Qk2mN4pR8",
"status": "completed",
"row_count": 2,
"imported_count": 1,
"updated_count": 0,
"skipped_count": 0,
"invalid_count": 1,
"error": null,
"rejected": [
{ "row": 2, "identifier": "not-an-email", "reason": "Invalid email address" }
],
"report_url": "https://…/imports/…-report.csv?signature=…",
"completed_at": "2026-09-24T09:25:47+00:00",
"created_at": "2026-09-24T09:24:11+00:00"
}
Deliberately never reports anything about deliverability. This endpoint only ever answers with the same validation/dedupe reasons the dashboard's own failure report shows (an invalid identity, an unrecognized stage, "already in your audience").
List imports#
GET /v1/imports
Cursor-paginated (pagination), newest first. Requires a project
query parameter, imports are listed one project at a time.
curl "https://api.getlaunchlist.com/v1/imports?project=prj_7Qk2mN4pR8&limit=25" \
-H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk"
Rate limit#
POST /v1/imports carries its own limiter on top of the per-workspace throttle:api budget: a
handful a minute, and a modest hourly cap. One call materializes a CSV and dispatches the whole
chunked import pipeline, nowhere near the cost the request-rate limit is sized for.
The import object#
| Field | Description |
|---|---|
id |
The imp_ handle. |
status |
processing, completed, or failed. |
row_count |
Rows submitted. |
imported_count / updated_count / skipped_count / invalid_count |
Outcome tallies. skipped_count is duplicates left alone; invalid_count is rejected rows. |
restored_count |
Removed contacts the list brought back. |
subscription_changed_count / subscription_kept_count |
With update_subscription: existing contacts whose subscription changed, and ones the file asked to move who stayed as they were. |
error |
Set only on failed, an infrastructure failure, not a per-row rejection. |
rejected / report_url |
Only on GET /v1/imports/{import}. See Check an import. |
completed_at / created_at |
ISO 8601, or null while still processing. |
Errors#
| Status | Code | Meaning |
|---|---|---|
400 |
parameter_missing |
project was not given on GET /v1/imports. |
403 |
insufficient_scope |
The key lacks imports:write. |
404 |
resource_missing |
No such project or import, or it belongs to another tenant. |
422 |
parameter_invalid |
The body failed validation, a row with no identity, an identifier_type other than email/phone, or more than 10,000 contacts. errors carries the per-row detail. |
429 |
rate_limit_exceeded |
Over the limit, see Rate limit above. |
Next#
- MCP server:
import_contactsandget_importdo the same thing for an agent, with a lower per-call cap and a requiredstatus(an agent must ask the founder about consent rather than default to one answer) - Webhooks: subscribe to
audience.importedinstead of polling - Create a contact: the signup pipeline, for a person actually signing up right now rather than an existing list
Common questions
How is this different from POST /v1/contacts?
POST /v1/contacts runs the real signup pipeline, dedupe, referral credit, a verification email when the form asks for one, automation enrollment, the works. POST /v1/imports is the dashboard's CSV import by API, none of that happens. Use it for a list you already own; use contacts.create for a person actually signing up right now.
Does anyone on the list get emailed?
No. That is the entire point of this endpoint. status defaults to subscribed, matching the dashboard's own default, but even a subscribed row triggers no welcome or verification email, it only marks the contact reachable by a future broadcast. A row can say consent unconfirmed or unsubscribed instead.
How do I know when it finished?
Poll GET /v1/imports/{import}. status moves from processing to completed (or failed), at which point the counts are final. A large import also fires one audience.imported event, subscribe to it over webhooks instead of polling if you would rather not.
What happens to a row I cannot use?
It is skipped and recorded, never silently dropped. GET /v1/imports/{import} returns up to 100 rejected rows with the reason, plus a signed report_url for the full CSV.