Import contacts in bulk

POST /api/v1/lists/{listId}/contacts/bulk Pushes up to 500 contacts in one call, with everything you know about them — including the consent proof of a migrated database. Each line is upserted by email, exactly like Add or update one contact. Each entry:
FieldRequiredDescription
emailyesThe upsert key, together with the list
firstNamenoUp to 100 characters
lastNamenoUp to 100 characters
tagsnoNames, created in the organization if they do not exist yet
statusnopending, subscribed, unsubscribed, bounced, complainedon creation only
optinAtnoISO date of the sign-up
optinIpnoIP of the sign-up
confirmedAtnoISO date of the double opt-in confirmation
confirmedIpnoIP of the confirmation
doubleOptInRefused: an import never emails anyone, the line is rejected
A batch is invisible to the contacts. An import is not a sign-up: nothing is sent, nothing is queued, no automation starts. No confirmation email, no sequence enrolment — not for a line declared subscribed, not for a tag added by the import. People who subscribed years ago in another tool must not receive a "Welcome, thanks for subscribing!" because you changed vendors. The status you declare is simply the one the contact is created with. For the same reason a batch entry refuses doubleOptIn: the line is rejected and named in the report, rather than accepted while no confirmation is ever sent. Sign-ups go through POST /api/v1/lists/{listId}/contacts, one contact at a time — that endpoint is the one that emails and enrols.
Consent fields are written, never blanked. A field you leave out keeps whatever is already stored — this is the one piece of a migrated database that cannot be reconstructed afterwards. And an existing contact keeps its status: declaring subscribed on someone who unsubscribed changes nothing, the report still counts the line as updated.

The report

The answer is a 200 whose data is the report of the batch:
json
{
  "success": true,
  "data": {
    "total": 500,
    "created": 468,
    "updated": 31,
    "rejected": 1,
    "errors": [{"index": 342, "email": "pas-un-email", "reason": "email: Invalid email address"}]
  }
}
FieldMeaning
totalLines submitted — always created + updated + rejected
createdContacts that did not exist in the list
updatedContacts already there, completed without ever changing their status
rejectedLines refused, detailed one by one in errors
errors{index, email, reason}index is the position in the batch you sent
One bad line rejects that line only. The 499 others are written. There is no global transaction: a single comma cannot cost you the whole batch.

The agent loop

index is what makes the report actionable: it points at the line in the array you sent, so a failure is replayed as itself, not as a full resend.
  1. Send a chunk of 500.
  2. Read errors.
  3. Rebuild a batch from the failing indexes only — errors.map(e => batch[e.index]) — fix them, send again.
  4. Repeat until rejected is 0, then move to the next chunk.
js
const send = async (batch) => {
  const response = await fetch(`${base}/api/v1/lists/${listId}/contacts/bulk`, {
    method: 'POST',
    headers: {'x-api-key': key, 'content-type': 'application/json'},
    body: JSON.stringify({contacts: batch}),
  })
  return (await response.json()).data
}

for (let offset = 0; offset < all.length; offset += 500) {
  const batch = all.slice(offset, offset + 500)
  const report = await send(batch)
  const failed = report.errors.map((error) => ({...batch[error.index], ...fix(error)}))
  if (failed.length > 0) await send(failed)
}
Re-sending a line that succeeded is harmless — the endpoint upserts — but the point is that you do not have to: 47 rejected tells you which 47.
StatusWhen
200Batch processed — read the report, some lines may have been rejected
400The body itself is malformed (contacts missing or not an array)
403The key's owner is a member: importing needs admin
404Unknown list, or a list of another organization
413More than 500 entries — nothing was written, nothing was truncated
Over the limit the call is refused whole, with the limit in the message: an agent that believes it pushed 800 contacts must never discover later that only 500 went in. Templates sent to that audience use merge tags — see Merge Tags Cheat Sheet. Writing those templates, checking their rendering and sending a campaign to this audience is the other half of the API — see Content & Send API.

Authorization

Role in the organizationThis endpoint
OWNERAllowed
ADMINAllowed
MEMBERRefused — 403
A key carries the role its creator holds in this organization — never a role on AgentsMail itself. See API keys.

Request body

FieldTypeRequiredDescription
contactsobject[]yesArray of entries, 500 maximum

Example

bash
curl -X POST -H "x-api-key: $AGENTMAIL_API_KEY" -H "content-type: application/json" \
  -d '{"contacts":[
        {"email":"camille@example.fr","firstName":"Camille","lastName":"Roux","tags":["vip"],
         "status":"subscribed","optinAt":"2024-03-12T09:14:00Z","optinIp":"81.250.14.7"},
        {"email":"theo@example.com","status":"unsubscribed"}
      ]}' \
  "https://www.agentsmail.io/api/v1/lists/$LIST_ID/contacts/bulk"

Response

  • 200 OK — the call succeeded.
  • 401 Unauthorized — missing or invalid key, or its owner left the organization.
  • 403 Forbidden — the key's owner is a member of the organization, not an admin.
  • 404 Not Found — no such resource, or it belongs to another organization. The API never confirms that an id exists to a caller who has no right to it.
  • 413 Payload Too Large — over 500 contacts in one call; the body names the limit.
  • 429 Too Many Requests — over 120 requests in a minute for this key.

Response fields

FieldTypeDescription
successbooleanIndicates if the operation was successful
data.totalnumberLines submitted — always created + updated + rejected
data.creatednumberContacts that did not exist in the list
data.updatednumberContacts already there, completed without their status ever changing
data.rejectednumberLines refused, detailed one by one in errors
data.errorsarray{index, email, reason}index is the position in the batch you sent

Example response

json
{
  "success": true,
  "data": {
    "total": 500,
    "created": 468,
    "updated": 31,
    "rejected": 1,
    "errors": [
      {
        "index": 342,
        "email": "not-an-email",
        "reason": "email: Invalid email address"
      }
    ]
  }
}