Import contacts
/api/v1/contacts/importAdd up to 10,000 contacts in one request, each with a name and any custom fields. New
contacts are added as subscribed. An address that is already a contact is left exactly as
it is (name, fields and status) and counted in skipped_duplicate: import never updates
anyone, so it can never re-subscribe somebody who unsubscribed. To update people, use
PUT /contacts.
A bad row never fails the request. A row with no address, an invalid address, or no value
for a custom field your account marks required is skipped, and the reply's errors names
it by row number. Everything else is imported.
Two ways to send contacts. Send JSON with a contacts array, as in the example below.
Or upload a CSV file as a form (multipart/form-data) in a field called file:
curl -X POST 'https://app.mailyte.com/api/v1/contacts/import' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
-F 'file=@contacts.csv' \
-F 'list_id=01JBT8XQ2M9WYC3K4F6R7S8T9V'The file's first row should be a header with an email column and, optionally, a name
column. Every other column becomes a custom field named after its header ("Signup Date"
becomes signup_date). Without a header, the first column is read as the email and the
second as the name. Send file or contacts, not both.
Checks. Once Bounce protection is on for your account, each new address uses one
check and is checked in the background: email_status reads pending until the answer
lands, then valid, risky, invalid or unknown. Read it with GET /contacts/{id}, or
subscribe to the contact.checked webhook. If your checks run short, as many new contacts
as there are checks for are checked and the rest are added as not_checked. With Bounce
protection switched off they are added as not_checked. Until then an import's addresses
are checked free, as before.
The reply carries X-Mailyte-Checks-Remaining and, when it applies,
X-Mailyte-Checks-Warning: running_low | ran_out, once Bounce protection is on.
Request body
contactsarray<object>requiredThe contacts to add, 1 to 10,000 per request.
emailstringrequiredTheir email address. Case does not matter:
Ada@Example.comandada@example.comare the same contact.namestringTheir name, as it should appear in your emails.
attributesobjectCustom fields as key–value pairs, for example
{"city": "Lagos", "plan": "pro"}. Use the keys fromGET /contact-fields; a key your account has not seen before is kept too.
list_idstringOptional. The ID of a list to put every address in this request on, new contacts and ones you already had. Copy it from Contacts → Lists in the dashboard, or read it from
GET /contact-lists.
Request
/api/v1/contacts/importcurl -X POST 'https://app.mailyte.com/api/v1/contacts/import' \
-H 'Authorization: Bearer mk_live_YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"list_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"contacts": [
{
"email": "ada@example.com",
"name": "Ada Obi"
},
{
"email": "tunde@example.com",
"name": "Tunde Bello",
"attributes": {
"city": "Lagos",
"plan": "pro"
}
}
]
}'const response = await fetch('https://app.mailyte.com/api/v1/contacts/import', {
method: 'POST',
headers: {
Authorization: 'Bearer mk_live_YOUR_API_KEY',
'Content-Type': 'application/json',
},
body: JSON.stringify({
"list_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"contacts": [
{
"email": "ada@example.com",
"name": "Ada Obi"
},
{
"email": "tunde@example.com",
"name": "Tunde Bello",
"attributes": {
"city": "Lagos",
"plan": "pro"
}
}
]
}),
});
const { data } = await response.json();import requests
response = requests.post(
"https://app.mailyte.com/api/v1/contacts/import",
headers={"Authorization": "Bearer mk_live_YOUR_API_KEY"},
json={
"list_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"contacts": [
{
"email": "ada@example.com",
"name": "Ada Obi"
},
{
"email": "tunde@example.com",
"name": "Tunde Bello",
"attributes": {
"city": "Lagos",
"plan": "pro"
}
}
]
},
)
data = response.json()["data"]<?php
$response = Http::withToken('mk_live_YOUR_API_KEY')
->post('https://app.mailyte.com/api/v1/contacts/import', [
'list_id' => '01JBT8XQ2M9WYC3K4F6R7S8T9V',
'contacts' => [
[
'email' => 'ada@example.com',
'name' => 'Ada Obi',
],
[
'email' => 'tunde@example.com',
'name' => 'Tunde Bello',
'attributes' => [
'city' => 'Lagos',
'plan' => 'pro',
],
],
],
]);
$data = $response->json('data');require "net/http"
require "json"
uri = URI("https://app.mailyte.com/api/v1/contacts/import")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer mk_live_YOUR_API_KEY"
request["Content-Type"] = "application/json"
request.body = {
"list_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
"contacts": [
{
"email": "ada@example.com",
"name": "Ada Obi"
},
{
"email": "tunde@example.com",
"name": "Tunde Bello",
"attributes": {
"city": "Lagos",
"plan": "pro"
}
}
]
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(request) }Response
Success.
dataobjectThe counters always sum:
imported + skipped_invalid + skipped_duplicate + skipped_over_plan == total.objectstringcontact_importtotalintegerRows we were given. For a CSV this is DATA rows — a recognised header row is not counted.
importedintegerRows that became new contacts.
skipped_invalidintegerRows skipped because the address was missing or invalid, or a required field was empty. Other rows still import.
skipped_duplicateintegerAddresses you already have, plus repeats in the file. Existing contacts are left as they are, so re-importing never re-subscribes anyone who unsubscribed.
skipped_over_planintegerRows past the subscribed contacts your Marketing plan holds. The rows that fit are imported;
plan_limitnames the plan that takes the rest.error_countintegerHow many rows failed in total. Equal to
skipped_invalid + skipped_duplicate + skipped_over_plan, and the number to trust —errorsbelow is capped, so a file where everything failed reports 4,000 here and lists 100.errorsarray<object>The rows that failed, in file order, up to 100.
error_countgives the full number.[]means every row was accepted.rowinteger1-based, counting DATA rows, so it lines up with your spreadsheet after the header.
emailstringThe address exactly as you sent it, before normalising — empty when the row had none.
reasonstringmissing_email | invalid_email | duplicate_in_file | already_exists | missing_required_field | over_planWhy the row failed.
duplicate_in_file: the address appears twice in your file.already_exists: you already have this contact.missing_required_field: a required contact field is empty, andmessagenames it.messagestringThe same thing in words, safe to show a person. For a duplicate it names the earlier row.
plan_limitobjectNull unless rows were stopped by your plan. Then the same context a 402 carries:
limit_type,limit,current,suggested_product,cta_labelandupgrade_url.listobjectThe list named by
list_id, with how many of the file's contacts joined it (new ones and ones you already had). Null when no list was given.idstringaddedinteger
Returned inside the standard envelope.
Errors
| Status | When |
|---|---|
401 | The API key is missing, unknown, revoked or expired. All four answer identically, on purpose: distinguishing them would confirm which keys exist. |
403 | The key is valid but may not do this: it lacks the required scope, its IP allowlist does not include you, or this endpoint does not accept API keys. |
404 | No such resource in this organization. |
422 | The request was understood but the values were not acceptable. |
429 | Too many requests, or the organization has spent its sending allowance. `Retry-After` says how long to wait. |
Every status, with what causes it and what to do, is on the error reference.