Add or update one contact

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 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.

Path parameters

ParameterTypeDescription
listIdstringRequired. The list the contact joins. A contact belongs to exactly one

Request body

FieldTypeRequiredDescription
emailstringyesThe upsert key, together with the list. Must be a valid address
firstNamestringnoUp to 100 characters
tagsstring[]noTag names, 1 to 50 characters each. Created in the organization if they do not exist yet
doubleOptInbooleannoAsk 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. Not 201: 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; details carries the field-level issues.
  • 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 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

FieldTypeDescription
successbooleanIndicates if the operation was successful
data.idstringUnique identifier for the contact
data.emailstringIts address — unique inside its list
data.firstNamestring | nullWhat you know of the person
data.lastNamestring | nullNever set by this endpoint; carried from an import or the interface
data.statusstringpending, subscribed, unsubscribed, bounced, complained
data.listIdstringThe list it belongs to
data.listobject{id, name} of that list
data.tagsarray{id, name} of every tag carried after the call
data.createdAtstringISO creation date
data.updatedAtstringISO 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 status is untouched: someone who unsubscribed stays unsubscribed, whatever this call carries. Putting a contact back to subscribed is an explicit act — see Resubscribe a contact.
  • doubleOptIn is a floor, not a switch. It can raise the requirement for this one call; it can never lower the organization's setting.
  • lastName is not accepted here. The single upsert takes firstName only; 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.