# 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. Source: https://getlaunchlist.com/help/docs/api/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}`](#check-an-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](#the-contact-input). | | `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](#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}`](/help/docs/api/contacts-update). | | `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. | > [!WARNING] `status: "subscribed"` is an attestation, not a formality > It is the same claim the dashboard's own "They asked to hear from me" checkbox makes the > founder tick before a CSV import queues. Send `unconfirmed` for a list you cannot vouch for: > the rows still land in the audience as Not subscribed, and are not reachable by email until you subscribe them. ### 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`. > [!NOTE] Removed contacts come back > If a row matches someone you removed from your audience (not erased, not blocked), they are put > back, with their referral credit and leaderboard place, and counted in `restored_count`. Before, > they were skipped as already in your audience, or edited while still removed. ## 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. ```json { "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](/help/docs/api/pagination)), newest first. Requires a `project` query parameter, imports are listed one project at a time. ```bash 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](#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](#rate-limit) above. | ## Next - [MCP server](/help/docs/mcp/overview): `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](/help/docs/plugins/webhook): subscribe to `audience.imported` instead of polling - [Create a contact](/help/docs/api/contacts-create): 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.