Import contacts

Goal: define custom fields, create contacts one at a time or in bulk from a CSV/XLSX file, track the import job, put contacts into a static list, and export everything held about one contact.
Prerequisites: an API key with the contacts:read and contacts:write scopes (see Quickstart); a lawful basis to email the people you import; Contacts & audiences for the underlying model.

Steps

1. Define a custom field (optional, once per field)

Contacts have built-in email, first_name, last_name and status. Anything else lives in custom. Defining a field gives the key a label and a type:

curl -X POST https://emails.routeon.io/api/v1/custom-fields 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{
    "key": "plan",
    "label": "Subscription plan",
    "type": "enum",
    "options": ["free", "pro", "enterprise"]
  }'

Expected response (201 Created):

{
  "status": "success",
  "http_code": 201,
  "payload": {
    "id": "<CUSTOM_FIELD_ID>",
    "tenant_id": "<TENANT_ID>",
    "key": "plan",
    "label": "Subscription plan",
    "type": "enum",
    "options": ["free", "pro", "enterprise"],
    "created_at": "2026-09-28T09:00:00Z"
  },
  "meta": {}
}

type is one of string, number, date, bool, enum; options applies to enum only. A key that already exists is 409 custom_field_key_exists. Keep keys to letters, digits, _ and - so they can be used in segment filters.

2. Create a single contact

curl -X POST https://emails.routeon.io/api/v1/contacts 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: signup-jane-example-com" 
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "custom": { "plan": "pro" },
    "consent_source": "signup_form",
    "consent_at": "2026-09-28T09:00:00Z"
  }'

201 Created returns the contact with its generated id and its status. An address that already exists in your account is 409; update that contact with PATCH /contacts/{id} instead. An address on your suppression list is refused with 422 — suppressed addresses cannot be re-created.

3. Import in bulk from CSV or XLSX

curl -X POST https://emails.routeon.io/api/v1/contacts/import 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -H "Idempotency-Key: crm-export-0001" 
  -d '{
    "csv": "email,first_name,plannjane@example.com,Jane,pronjohn@example.com,John,freen",
    "mapping": { "email": "email", "first_name": "first_name", "plan": "plan" },
    "lawful_basis_attested": true,
    "default_status": "subscribed",
    "list_id": "<LIST_ID>"
  }'

Expected response (202 Accepted):

{
  "status": "success",
  "http_code": 202,
  "payload": { "job_id": "<JOB_ID>", "status": "pending" },
  "meta": {}
}
  • Send the file as raw CSV text in csv, or base64-encoded in file; set "format": "xlsx" to parse file as a spreadsheet. The first row is the header.
  • mapping maps each file column to a contact field: email is required, first_name and last_name are built-in, and any other target is stored under custom with that key. Unmapped columns are ignored.
  • lawful_basis_attested: true is required — your attestation that you may email these people.
  • default_status (subscribed or pending) applies to newly created contacts only.
  • list_id (optional) attaches every created or updated contact to that static list.
  • Limits: 5 MB of request body and 50,000 data rows per file. A file over the row limit is rejected as a whole — the job ends failed and nothing from it is imported — so split large files.

Re-importing an address that already exists updates that contact: a non-empty cell overwrites the stored value, an empty cell changes nothing, custom values are merged key by key, and the contact’s status is left as it is. Rows for suppressed addresses are skipped. Contacts created by an import do not start automations.

4. Poll the import job

curl https://emails.routeon.io/api/v1/contacts/import/<JOB_ID> 
  -H "Authorization: Bearer <API_KEY>"

Expected response once finished (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": {
    "job_id": "<JOB_ID>",
    "status": "completed",
    "summary": {
      "created": 1,
      "updated": 1,
      "skipped_suppressed": 0,
      "invalid": 0,
      "errors": []
    }
  },
  "meta": {}
}

status moves pending → processing → completed or failed. On completed, summary has the counts and errors[] lists rejected rows by 1-based row number (the header is row 1). On failed, the whole file was rejected and summary.error says why — for example the row limit or an unreadable file.

5. Add contacts to a list

Create a static list, then add or remove members by contact id:

curl -X POST https://emails.routeon.io/api/v1/lists 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{ "name": "Newsletter subscribers" }'

201 Created returns the list with kind: "static" and its id.

curl -X POST https://emails.routeon.io/api/v1/lists/<LIST_ID>/members 
  -H "Authorization: Bearer <API_KEY>" 
  -H "Content-Type: application/json" 
  -d '{ "add": ["<CONTACT_ID_1>", "<CONTACT_ID_2>"] }'

The call answers with a 2xx status and a payload of { "list_id": "<LIST_ID>", "member_count": 2 }. add and remove each take up to 1000 contact ids and can be combined in one call. If any id is not a contact of your account, the call is 403 contact_not_in_tenant and nothing is written. Find contact ids with GET /contacts?q=<email>; list members with GET /contacts?list_id=<LIST_ID>.

6. Export a contact

curl https://emails.routeon.io/api/v1/contacts/<CONTACT_ID>/export 
  -H "Authorization: Bearer <API_KEY>"

Expected response (200 OK):

{
  "status": "success",
  "http_code": 200,
  "payload": {
    "contact": { "id": "<CONTACT_ID>", "email": "jane@example.com", "status": "subscribed", "first_name": "Jane", "last_name": "Doe", "custom": { "plan": "pro" } },
    "events": [
      { "id": "<EVENT_ID>", "contact_id": "<CONTACT_ID>", "name": "order.completed", "properties": { "order_id": "48213" }, "occurred_at": "2026-09-28T09:05:00Z", "created_at": "2026-09-28T09:05:01Z" }
    ],
    "smtp_inbound_messages": [],
    "exported_at": "2026-09-28T09:10:00Z"
  },
  "meta": {}
}

Use this for a subject-access request. events holds the custom events recorded for the contact with POST /events; smtp_inbound_messages holds metadata (never content) of mail received over SMTP for this address, and is empty if you do not send over SMTP. Delivery history is not part of the export — query GET /messages?email=<address>. For erasure, use POST /contacts/{id}/erase, which anonymizes the contact and suppresses the address permanently.

Verify

GET /contacts?q=jane@example.com shows the contact with the expected status and custom values. For a bulk import, GET /contacts/import/{job_id} reaching completed with the expected counts is the signal — check summary.errors even on a completed job.

Common failures

Symptom Cause Fix
409 on POST /contacts The address already exists in your account PATCH /contacts/{id}, or import (imports update existing contacts)
422 on POST /contacts The address is suppressed, or a field is invalid Check meta.validation_error; a suppressed address cannot be re-created
409 custom_field_key_exists The custom field key is already defined Reuse the existing field (GET /custom-fields) or pick another key
413 on POST /contacts/import Request body over 5 MB Split the file
422 on POST /contacts/import lawful_basis_attested not true, no column mapped to email, no csv/file, or an invalid list_id Fix the request as meta.validation_error describes
Job failed with summary.error The file was rejected as a whole — more than 50,000 rows, or unreadable Fix or split the file and import again
summary.invalid > 0 Some rows were malformed, e.g. an invalid address Fix the rows listed in summary.errors and re-import them
Rows missing after import Their addresses are suppressed Expected — see summary.skipped_suppressed
403 contact_not_in_tenant on /lists/{id}/members At least one id is not a contact of your account; nothing was written Remove the foreign ids and retry
404 on /lists/{id}/members The list does not exist or was deleted Check the id with GET /lists