POST /api/v1/lists/{listId}/contacts
Adds a contact to a list, or completes the one already there — the email address is the key, inside
that list. This is the endpoint a funnel or a lead magnet calls: unlike the bulk import, it emits
side effects, so an enrolment event fires and a double opt-in confirmation goes out when the
organization requires one.
Authorization
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed |
ADMIN | Allowed |
MEMBER | Refused — 403 |
Path parameters
| Parameter | Type | Description |
|---|---|---|
listId | string | Required. The list the contact joins. A contact belongs to exactly one |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email | string | yes | The upsert key, together with the list. Must be a valid address |
firstName | string | no | Up to 100 characters |
tags | string[] | no | Tag names, 1 to 50 characters each. Created in the organization if they do not exist yet |
doubleOptIn | boolean | no | Ask for a confirmation even when the organization does not require one. It can never skip one it does require |
json
{
"email": "ada@example.com",
"firstName": "Ada",
"tags": ["vip", "webinar-march"],
"doubleOptIn": true
}Example
bash
curl -X POST -H "x-api-key: $AGENTMAIL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "ada@example.com", "firstName": "Ada", "tags": ["vip"]}' \
"https://www.agentsmail.io/api/v1/lists/$LIST_ID/contacts"Response
200 OK— the contact, created or completed. Not201: the semantics are an upsert, and you cannot tell from the call which of the two happened.400 Bad Request— invalid address, a tag over 50 characters, a malformed body;detailscarries the field-level issues.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 list, or it belongs to another organization. The API never confirms that an id exists to a caller who has no right to it.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.id | string | Unique identifier for the contact |
data.email | string | Its address — unique inside its list |
data.firstName | string | null | What you know of the person |
data.lastName | string | null | Never set by this endpoint; carried from an import or the interface |
data.status | string | pending, subscribed, unsubscribed, bounced, complained |
data.listId | string | The list it belongs to |
data.list | object | {id, name} of that list |
data.tags | array | {id, name} of every tag carried after the call |
data.createdAt | string | ISO creation date |
data.updatedAt | string | ISO date of the last change |
Example response
json
{
"success": true,
"data": {
"id": "4d19…",
"email": "ada@example.com",
"firstName": "Ada",
"lastName": null,
"status": "subscribed",
"listId": "0b7c…",
"list": {"id": "0b7c…", "name": "Newsletter"},
"tags": [{"id": "7c2e…", "name": "vip"}],
"createdAt": "2026-08-23T10:22:05.000Z",
"updatedAt": "2026-08-23T10:22:05.000Z"
}
}Notes
- An existing contact is completed, never reset. Its
statusis untouched: someone who unsubscribed stays unsubscribed, whatever this call carries. Putting a contact back tosubscribedis an explicit act — see Resubscribe a contact. doubleOptInis a floor, not a switch. It can raise the requirement for this one call; it can never lower the organization's setting.lastNameis not accepted here. The single upsert takesfirstNameonly; a full name comes in through Import contacts in bulk or the interface.- Tags are added, never replaced: a contact keeps the labels it already carried.