Contacts
{contact} is the contact id or the email address (URL-encoded): /v1/contacts/ada%40example.com. Contacts explained.
The contact object
{
"id": "0d9e8f7a-6b5c-4d3e-2f1a-0b9c8d7e6f5a",
"email": "ada@example.com",
"firstName": "Ada",
"lastName": "Lovelace",
"attributes": {
"plan": "pro",
"company": "Analytical Engines",
"seats": 12,
"trial": false
},
"status": "ACTIVE",
"source": "API",
"mailboxProvider": "GOOGLE",
"underReview": false,
"createdAt": "2026-09-02T10:15:00Z",
"lastSentAt": "2026-10-15T08:00:12Z",
"lastOpenedAt": "2026-10-15T09:12:40Z",
"lastClickedAt": null
}
| Field | Values |
|---|---|
status | ACTIVE, UNSUBSCRIBED (from everything), BOUNCED, COMPLAINED |
source | How the contact arrived: FORM, API, IMPORT, DASHBOARD, MCP |
mailboxProvider | Who hosts the inbox, found from the domain's MX records: GOOGLE, MICROSOFT, YAHOO, AOL, APPLE, OTHER, UNKNOWN |
underReview | true for a contact who has not opened, clicked or confirmed yet |
Attributes
Data to personalize emails ({{ contact.plan }}) or filter segments.
- Up to 50 attributes per contact.
- Keys: 1 to 50 lowercase letters, digits or underscores.
email,first_nameandlast_nameare reserved: use the contact fields. - Values: string (1,000 characters at most), number, boolean or
null. Lists and objects are refused. Types are kept, so"seats": 12can be compared withgreater_than. - Updates are merged: send only the keys that change. A
nullvalue removes the key.
List contacts
GET /v1/contacts
Newest first.
ACTIVE, UNSUBSCRIBED, BOUNCED or COMPLAINED.listId: only PENDING, SUBSCRIBED or UNSUBSCRIBED members.0.curl "https://api.kulamail.com/v1/contacts?listId=5a8e…&size=100" \
-H "Authorization: Bearer ml_your_api_key"
{
"contacts": [ { "id": "0d9e8f7a-…", "email": "ada@example.com", "…": "…" } ],
"page": 0,
"size": 100,
"total": 1284,
"totalPages": 13
}
Create or update a contact
POST /v1/contacts
Creates the contact, or updates it if the address already exists in the space.
firstName.true sends a confirmation email first: subscriptions stay PENDING until the person clicks the link. Default false.curl https://api.kulamail.com/v1/contacts \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"firstName": "Ada",
"attributes": { "plan": "trial", "signup_date": "2026-10-17" },
"listIds": ["5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f"]
}'
const response = await fetch('https://api.kulamail.com/v1/contacts', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.KULA_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: user.email,
firstName: user.firstName,
attributes: { plan: 'trial', signup_date: '2026-10-17' },
listIds: [PRODUCT_UPDATES_LIST_ID]
})
})
const contact = await response.json()
Returns the contact object: 201 when created, 200 when updated.
A subscription without double opt-in is active at once: it starts the flows triggered by this list and sends the contact.subscribed webhook.
| Status | Code | When |
|---|---|---|
| 400 | validation_error, bad_request | Invalid address, attributes, or a domain that cannot receive email |
| 404 | not_found | A list in listIds does not exist |
| 409 | contact_unreachable | Subscribing an address that bounced or reported spam |
Get a contact
GET /v1/contacts/{contact}
The contact, its subscriptions, its activity counts, and its last 20 events and emails.
curl https://api.kulamail.com/v1/contacts/ada%40example.com \
-H "Authorization: Bearer ml_your_api_key"
{
"contact": { "id": "0d9e8f7a-…", "email": "ada@example.com", "status": "ACTIVE", "…": "…" },
"subscriptions": [
{
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
"listName": "Product updates",
"status": "SUBSCRIBED",
"source": "API",
"subscribedAt": "2026-10-17T09:30:00Z",
"unsubscribedAt": null
}
],
"counts": { "sent": 14, "opened": 9, "clicked": 3 },
"events": [
{ "id": "e1f2…", "name": "trial_started", "properties": { "plan": "pro" }, "occurredAt": "2026-10-17T09:31:00Z" }
],
"messages": [
{
"id": "8f6c2a1e-…",
"subject": "Welcome to Acme",
"source": "FLOW",
"status": "DELIVERED",
"sentAt": "2026-10-17T09:31:05Z",
"openedAt": "2026-10-17T09:40:00Z",
"clickedAt": null
}
]
}
Update a contact
PATCH /v1/contacts/{contact}
Changes names and attributes. Fields left out stay as they are; attributes are merged.
null removes a key.curl -X PATCH https://api.kulamail.com/v1/contacts/ada%40example.com \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "attributes": { "plan": "pro", "trial": null } }'
Returns the updated contact object.
Delete a contact
DELETE /v1/contacts/{contact}
Deletes the contact, its subscriptions and its events. Its sent emails stay in your reports without the address. Answers 204.
A suppressed address stays on the suppression list after deletion.
true for a data deletion request: the address also leaves the suppression list, so it could be emailed again. With an email address in the path, works even when the address isn't a contact, such as the recipient of a transactional email. Answers 404 when there is nothing to erase.Subscribe to a list
POST /v1/contacts/{contact}/subscriptions
Also resubscribes a contact who had unsubscribed: only call it when the person asked.
true sends a confirmation email first. Default false.curl https://api.kulamail.com/v1/contacts/ada%40example.com/subscriptions \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f" }'
{
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
"listName": "Product updates",
"status": "SUBSCRIBED",
"source": "API",
"subscribedAt": "2026-10-17T09:30:00Z",
"unsubscribedAt": null
}
status is PENDING with double opt-in. Answers 409 contact_unreachable for an address that bounced or reported spam.
Unsubscribe from a list
DELETE /v1/contacts/{contact}/subscriptions/{listId}
Answers 204, also when the contact was not subscribed. Flows started by this list stop for this contact.
Unsubscribe from everything
POST /v1/contacts/{contact}/unsubscribe
Unsubscribes the contact from every list: its status becomes UNSUBSCRIBED. Returns the contact object.
Transactional emails still reach an unsubscribed contact; only campaigns and flows stop.
Import contacts
POST /v1/imports
Imports up to 10,000 contacts into a list, in the background. Follow progress with Get an import.
Same rules as a dashboard CSV import (details):
- invalid addresses, role addresses (
info@,noreply@…), duplicates, domains that cannot receive email and suppressed addresses are skipped; - existing contacts get their names and attributes updated, never their status: an unsubscribed contact stays unsubscribed;
- imports trigger no flow and no
contact.subscribedwebhook.
email (required), firstName, lastName and attributes.curl https://api.kulamail.com/v1/imports \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
"contacts": [
{ "email": "ada@example.com", "firstName": "Ada", "attributes": { "plan": "pro" } },
{ "email": "grace@example.com", "firstName": "Grace" }
]
}'
Returns 202 Accepted with the import object. Larger files: split them, or use the CSV route below.
Import a CSV file
POST /v1/imports/csv?listId={listId}
The CSV file is the raw request body, with Content-Type: text/csv. 50 MB at most, with a header row; columns separated by ,, ; or tabs.
Columns are recognized by their header: email, first_name / firstname, last_name / lastname and common variants. Other columns become attributes. To override, pass a mapping parameter: a JSON object from column name to email, first_name, last_name, attributes.<key> or ignore.
curl "https://api.kulamail.com/v1/imports/csv?listId=5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f" \
-H "Authorization: Bearer ml_your_api_key" \
-H "Content-Type: text/csv" \
--data-binary @subscribers.csv
Returns 202 Accepted with the import object. Same rules as POST /v1/imports.
Get an import
GET /v1/imports/{id}
Progress, counts, and the first 50 rejected rows. GET /v1/imports lists the last 100 imports of the space, newest first.
The import object
{
"id": "9c8b7a6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d",
"listId": "5a8e2c1f-0b3d-4e6a-9f7c-2d1e8b4a6c3f",
"fileName": null,
"status": "COMPLETED",
"columnMapping": {},
"totalRows": 2,
"processedRows": 2,
"createdCount": 1,
"updatedCount": 0,
"invalidSyntaxCount": 0,
"invalidDomainCount": 0,
"duplicateCount": 0,
"suppressedCount": 0,
"roleCount": 1,
"errorSample": [
{ "row": 2, "email": "info@example.com", "reason": "role_address" }
],
"errorMessage": null,
"createdVia": "API",
"createdAt": "2026-10-17T09:30:00Z",
"startedAt": "2026-10-17T09:30:01Z",
"completedAt": "2026-10-17T09:30:02Z"
}
status goes PENDING → PROCESSING → COMPLETED, or FAILED with an errorMessage. Rejection reasons: invalid_syntax, role_address, duplicate, invalid_domain, suppressed.