Skip to content

POST /api/public/v1/contacts/import ​

Bulk-upserts contacts into your organization, optionally tagging them and enrolling them into campaigns as they land. This is the same operation the dashboard's CSV wizard performs, for a caller who has a CRM instead of a spreadsheet.

PropertyValue
MethodPOST
Path/api/public/v1/contacts/import
Scopescontact:write, plus campaign:write conditionally — see below
Org kindBuyer organizations only
Cost10 rate-limit tokens
Body limit2 MB
Rows1–500 per request
RetriesSafe without a key; idempotency-key also replays the exact counts

Scopes ​

contact:write is always required.

campaign:write is required only when the body names a campaign — whether through the top-level campaign_ids or any row's own campaign_ids. A request with no campaigns anywhere needs only contact:write.

This is conditional rather than static on purpose. Keys are minted with every read and no write by default, so every live import key was created with contact:write and nothing more. Demanding campaign:write unconditionally would reject every existing key on a body that has always worked.

When the body asks for a campaign and the key cannot write campaigns, the request is refused outright:

json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "This API key is missing the campaign:write scope."
  }
}

The refusal is loud deliberately. Importing the contacts and quietly ignoring the campaign would leave you certain your campaign was filled, and the emptiest possible way to discover otherwise is an agent pressing Next and getting nobody.

Organization kind ​

Contacts are buyer-side. A seller-side organization gets 403 regardless of scopes, because the rows would land in a tenant whose own interface cannot list, DNC or delete them.

Why JSON and not a file upload ​

Everything hard about the CSV wizard — parsing without turning 0821234567 into a number, guessing header aliases, the column-mapping interface, the preview round-trip — exists because a spreadsheet is unstructured. Your data is already structured. Accepting multipart here would mean re-solving all of it inside a serverless function, without the preview that makes a mis-mapped column fixable, for a caller who never had the problem.

Request ​

json
{
  "rows": [
    {
      "phone": "0821234567",
      "first_name": "Thandi",
      "last_name": "Nkosi",
      "email": "thandi@example.com",
      "custom_fields": { "account_ref": "AC-1042" }
    },
    {
      "phone": "+27829876543",
      "first_name": "Sipho",
      "campaign_ids": ["7e8f9a0b-1c2d-4e3f-a4b5-c6d7e8f9a0b1"]
    }
  ],
  "campaign_ids": ["b7c1f0e2-2f1a-4c5e-9f3b-6d2e8a4c1b90"],
  "tags": ["Q3 renewals"],
  "tag_ids": ["d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f70"],
  "country": "ZA"
}

Top-level fields ​

FieldTypeRequiredNotes
rowsarrayYes1–500 rows. More than 500 is INVALID_INPUT.
campaign_idsstring[]NoUUIDs. Applied to every row. Defaults to [].
tagsstring[]NoTag names, ≤ 40 characters each. Created if new. See below.
tag_idsstring[]NoUUIDs of tags you already have. Must exist. See below.
countrystringNoDefaults to "ZA". Used to interpret national-format numbers.

Row fields ​

FieldTypeRequiredLimit
phonestringYes1–64 characters
first_namestringNo≤ 120 characters
last_namestringNo≤ 120 characters
emailstringNo≤ 255 characters
custom_fieldsobjectNo≤ 25 keys; key ≤ 120 chars, value ≤ 500 chars
campaign_idsstring[]NoUUIDs, for this row only. Defaults to [].

Strings are trimmed. custom_fields values must be strings — numbers and nulls are rejected rather than coerced.

Custom fields ​

Every key in custom_fields lands in one of the organisation's contact fields — the same list managed under Contacts → Fields in the dashboard, which scripts read as {{ custom.<key> }} and the contact list filters by.

  • A key is matched to an existing field by its exact key first, then by its slug: lower-cased, with every run of other characters turned into _. So "Policy No", "policy no" and "policy_no" all reach a field keyed policy_no.
  • A key that matches no field is not stored. Its values are reported in field_issues and the row is written with everything else. Importing never creates a field: add it under Contacts → Fields first, where you also choose its type.
  • Values are checked against the field's type — text, number, date, yes/no, choice, phone number, email or link — and stored normalised ("R 1 250" is stored as 1250, 01/11/2026 as 2026-11-01, a phone number in E.164). Dates are read day-first.
  • A value that does not fit is dropped, not the row. The contact is written with everything else, and the cell is reported in field_issues — the same treatment an unreadable email gets.
  • An empty string keeps what the contact already had; an import never clears a field.

phone is accepted in national or E.164 format; country decides how a national number is interpreted. It is normalised before storage, which is also what makes the upsert idempotent.

Campaign bounds ​

An import may enrol into at most 5 campaigns in total, counting the top-level campaign_ids and every row's own together as a set. Exceeding it is INVALID_INPUT on the whole request.

Rows are enrolled into the union of the top-level list and their own.

Tagging ​

Both tag fields apply to every contact in the request — there is no per-row tag. A row-level tag would be a mapped spreadsheet column in the dashboard wizard, which was rejected there, and offering it here would commit us to it.

They differ in one way, and it is the only thing worth knowing about them:

  • tags are names, and a name you send is created if it does not exist. Matching ignores capitalisation and collapses runs of whitespace, so "VIP" and "vip" are one tag, as are "Cape Town" and "Cape Town". The stored spelling is the first one that reached us, and it is what the response echoes. A typo creates a tag rather than failing your import — the trade that lets you import without reading our tag ids from anywhere first.
  • tag_ids are ids, and an id you send must already exist in your organization. An id we cannot find is 404 NOT_FOUND naming it, and nothing is written — not the contacts, not the other tags. Ids are checked before any row is touched.

Prefer tag_ids when you have the id. It cannot create a tag by accident, and it still means the same tag after somebody renames it — a name would be re-created under the old spelling. Ids appear in the dashboard's own URLs (/contacts?tag=<uuid>) and in this endpoint's response.

Both lists may be sent together. The same tag named in both is one tag, reported once.

A do-not-call contact is tagged. Only campaign enrolment is withheld from one — see dnc_rows below. Refusing to label a DNC contact would leave you unable to segment the very people you most need to keep track of.

Tag bounds ​

An import may apply at most 10 tags, counting tags and tag_idstogether. Ten names plus one id is INVALID_INPUT on the whole request.

Separately, an organization may hold at most 200 tags. A request whose new names would take you past that is refused before anything is written, rather than creating some of them and then failing.

Chunking ​

One chunk per request, up to 500 rows and 2 MB. Both ceilings are real: a request body caps at roughly 4.5 MB on our platform, and 500 rows is about 4 000 bind parameters against PostgreSQL's 65 535-per-statement limit.

For more than 500 contacts, loop. There is no batch handle to open or close — each request stands alone.

Response ​

200 OK — not 201. One request creates some rows and updates others; there is no single resource to point a Location at, and reporting the mixed outcome in the body is the only honest answer.

json
{
  "data": {
    "import_batch_id": "9a2f4c61-8b3d-4e52-a1c7-0f5b6d8e2a34",
    "created": 412,
    "updated": 80,
    "skipped": [
      { "row": 17, "value": "not-a-number", "message": "Invalid phone number" }
    ],
    "merged": [203],
    "campaigns": [
      { "campaign_id": "b7c1f0e2-2f1a-4c5e-9f3b-6d2e8a4c1b90", "added": 490 }
    ],
    "dnc_rows": [88, 341],
    "tags": [
      {
        "tag_id": "e1f2a3b4-c5d6-4e7f-8a9b-0c1d2e3f4a5b",
        "name": "Q3 renewals",
        "attached": 492
      },
      {
        "tag_id": "d4e5f6a7-b8c9-4d0e-8f1a-2b3c4d5e6f70",
        "name": "Inbound",
        "attached": 12
      }
    ],
    "field_issues": [
      {
        "row": 42,
        "key": "renewal_date",
        "value": "soon",
        "message": "Renewal date needs to be a valid date."
      }
    ]
  }
}
FieldTypeMeaning
import_batch_idstringIdentifies this run. Quote it to reverse the import or to get help.
creatednumberContacts that did not exist before.
updatednumberExisting contacts whose details were updated.
skippedobject[]Rows refused, with the offending value and why.
mergednumber[]Row numbers folded into an earlier row of the same chunk.
campaignsobject[]Every campaign requested, with how many rows it newly gained.
dnc_rowsnumber[]Rows imported but not enrolled, because the contact is DNC.
tagsobject[]Every tag requested, with how many contacts it newly gained.
field_issuesobject[]Custom field values not stored; the row itself was written.

Row numbers are 1-based indices into the rows array you sent, so 17 is rows[16].

import_batch_id leads because it is the only value here you did not already know. We generate it — you cannot supply one.

field_issues names each cell by row and by the field key it was stored under (after the matching above) — or by the key you sent, when no field matched it — with your value and why it was not stored. Like dnc_rows, these rows landed — re-send them only with the value corrected, and re-sending is safe.

tags reports your tags names first and then your tag_ids, each in the order you sent them, and carries the tag's tag_id and its stored name — so a name resolves to an id you can use next time, and an id resolves to the name it currently has. attached counts the contacts in this chunk that did not already carry the tag.

Five behaviours that will bite you if you assume otherwise ​

1. Re-posting a chunk is safe. The upsert is idempotent: applied twice equals applied once. On a timeout or a 500, re-post the identical body. A chunk is one transaction, so it never lands halfway. Without a key, the re-post's counts describe the re-post — rows the first attempt created come back as updated, and added / attached under-count. Send an idempotency-key and a retry after a success gets the first attempt's response back, counts and import_batch_id included.

2. Duplicate numbers inside a chunk are merged, not rejected. Two rows normalising to the same number become one contact, and the later row's number appears in merged. This matters because an API caller is more likely to send duplicates than a CSV export is, and the database will not accept a statement that touches one row twice — one repeated number would otherwise destroy the other 499 good rows.

3. dnc_rows is not a subset of skipped. They are different outcomes:

  • A skipped row was not written at all. Fix it and re-send it.
  • A dnc_rows row was written — the contact's name, email and custom fields were updated exactly as asked. Only the campaign enrolment was withheld, because that contact is on do-not-call.

A client that treats them the same will re-post those rows forever waiting for them to land. dnc_rows only ever names rows that asked for a campaign; a DNC contact in a chunk with no campaigns has nothing withheld and nothing to report.

4. A campaign that gained nobody still appears, with added: 0. "Everyone was already enrolled" and "we ignored the campaign id you sent" have to be distinguishable, and only one of them is fine. campaigns lists every campaign you asked for, in the order you named them.

5. A tag name creates; a tag id must exist. These are not two spellings of the same field. An unrecognised name in tags becomes a new tag and the import succeeds; an unrecognised id in tag_ids is 404 and the whole request writes nothing. If you are generating tag names from customer data, expect to find tags you did not intend on /contacts/tags — that is the cost of names, and it is one click to delete.

A related guarantee worth stating: a do-not-call contact cannot be resurrected through this endpoint. The do-not-call list is keyed on the phone number, not on the contact record, so re-posting a listed number — even after its contact was deleted — updates the details and leaves it on do-not-call. To add numbers to the list, see suppressions.

Errors ​

SituationStatusCode
Body over 2 MB413TOO_LARGE
Missing or wrong content-type415UNSUPPORTED_MEDIA_TYPE
Malformed JSON400INVALID_INPUT
0 rows, over 500 rows, over 5 campaigns400INVALID_INPUT
Over 10 tags, counting tags and tag_ids400INVALID_INPUT
New names would take the org past 200 tags400INVALID_INPUT
A tag_ids entry this organization does not hold404NOT_FOUND
A campaign_ids entry it does not hold404NOT_FOUND
A field over its length limit400INVALID_INPUT
Key lacks contact:write403FORBIDDEN
Body names a campaign, key lacks campaign:write403FORBIDDEN
Seller-side organization403FORBIDDEN
Budget exhausted (10 tokens per request)429TOO_MANY_REQUESTS

TOO_LARGE is decided from content-length before your key is verified, so an oversized body spends no tokens. Send content-length: a chunked body is measured as it is read, after the key is verified, and its 413 spends them.

Validation errors carry path, so ["rows", 16, "phone"] names the exact field. Note that validation is all-or-nothing: a single bad row fails the whole request rather than importing the other 499. skipped is for rows that were structurally valid but that we refused on inspection — an unparseable phone number, for instance.

Suggested client loop ​

for each chunk of up to 500 rows:
    post the chunk
    on 429  -> sleep retry-after, retry the same chunk
    on 5xx  -> exponential backoff, retry the same chunk
    on 4xx  -> stop; the request is wrong, not unlucky
    on 200  -> record import_batch_id
               re-send skipped[] rows only after fixing them
               do NOT re-send dnc_rows[] — they landed

Sources: src/routes/api/public/v1/contacts/import/+server.ts, ContactImportSchema.api in src/lib/schema/contact/contact_import.schema.ts, and CONTACTS.IMPORT / CONTACTS.TAG in src/lib/const/contact.const.ts.