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 infile; set"format": "xlsx"to parsefileas a spreadsheet. The first row is the header. mappingmaps each file column to a contact field:emailis required,first_nameandlast_nameare built-in, and any other target is stored undercustomwith that key. Unmapped columns are ignored.lawful_basis_attested: trueis required — your attestation that you may email these people.default_status(subscribedorpending) 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
failedand 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 |