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.

Updated ·6 min read
POST/v1/imports

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:

  • subscribed subscribes a contact who is not subscribed, and lifts an unsubscribe a teammate made.
  • unsubscribed unsubscribes them.
  • unconfirmed does 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_contacts and get_import do the same thing for an agent, with a lower per-call cap and a required status (an agent must ask the founder about consent rather than default to one answer)
  • Webhooks: subscribe to audience.imported instead 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.

Was this page helpful?
curl https://api.getlaunchlist.com/v1/imports \
  -H "Authorization: Bearer llk_live_8xKq3Rp9Wm2Nz7Vt6yL1cG5dH0jA4sE8uNqB2fXk" \
  -H "Content-Type: application/json" \
  -d '{
    "project": "prj_7Qk2mN4pR8",
    "contacts": [
      { "email": "ada@lovelace.dev", "name": "Ada Lovelace" },
      { "email": "grace@example.com", "name": "Grace Hopper" }
    ],
    "duplicates": "skip",
    "status": "unconfirmed",
    "tags": ["imported-2026-q3"]
  }'
{
  "object": "import",
  "id": "imp_9phm4rtx2kqd",
  "project": "prj_7Qk2mN4pR8",
  "status": "processing",
  "row_count": 2,
  "imported_count": 0,
  "updated_count": 0,
  "skipped_count": 0,
  "invalid_count": 0,
  "error": null,
  "completed_at": null,
  "created_at": "2026-09-24T09:24:11+00:00"
}