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:
| Field | Required | Description |
|---|---|---|
email | yes | The upsert key, together with the list |
firstName | no | Up to 100 characters |
lastName | no | Up to 100 characters |
tags | no | Names, created in the organization if they do not exist yet |
status | no | pending, subscribed, unsubscribed, bounced, complained — on creation only |
optinAt | no | ISO date of the sign-up |
optinIp | no | IP of the sign-up |
confirmedAt | no | ISO date of the double opt-in confirmation |
confirmedIp | no | IP of the confirmation |
doubleOptIn | — | Refused: an import never emails anyone, the line is rejected |
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 a200 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"}]
}
}| Field | Meaning |
|---|---|
total | Lines submitted — always created + updated + rejected |
created | Contacts that did not exist in the list |
updated | Contacts already there, completed without ever changing their status |
rejected | Lines refused, detailed one by one in errors |
errors | {index, email, reason} — index is the position in the batch you sent |
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.
- Send a chunk of 500.
- Read
errors. - Rebuild a batch from the failing indexes only —
errors.map(e => batch[e.index])— fix them, send again. - Repeat until
rejectedis0, 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)
}47 rejected tells you which 47.
| Status | When |
|---|---|
200 | Batch processed — read the report, some lines may have been rejected |
400 | The body itself is malformed (contacts missing or not an array) |
403 | The key's owner is a member: importing needs admin |
404 | Unknown list, or a list of another organization |
413 | More than 500 entries — nothing was written, nothing was truncated |
Authorization
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed |
ADMIN | Allowed |
MEMBER | Refused — 403 |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
contacts | object[] | yes | Array 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 amemberof the organization, not anadmin.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 thelimit.429 Too Many Requests— over 120 requests in a minute for this key.
Response fields
| Field | Type | Description |
|---|---|---|
success | boolean | Indicates if the operation was successful |
data.total | number | Lines submitted — always created + updated + rejected |
data.created | number | Contacts that did not exist in the list |
data.updated | number | Contacts already there, completed without their status ever changing |
data.rejected | number | Lines refused, detailed one by one in errors |
data.errors | array | {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"
}
]
}
}