API reference

Contacts

Create and update contacts, manage subscriptions, import in bulk

{contact} is the contact id or the email address (URL-encoded): /v1/contacts/ada%40example.com. Contacts explained.

You answer for the consent of the contacts you add. Whatever the route, contacts who never opened are sent to progressively.

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
}
FieldValues
statusACTIVE, UNSUBSCRIBED (from everything), BOUNCED, COMPLAINED
sourceHow the contact arrived: FORM, API, IMPORT, DASHBOARD, MCP
mailboxProviderWho hosts the inbox, found from the domain's MX records: GOOGLE, MICROSOFT, YAHOO, AOL, APPLE, OTHER, UNKNOWN
underReviewtrue 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_name and last_name are 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": 12 can be compared with greater_than.
  • Updates are merged: send only the keys that change. A null value removes the key.

List contacts

GET /v1/contacts

Newest first.

search
string
Part of the address or of the name.
status
string
ACTIVE, UNSUBSCRIBED, BOUNCED or COMPLAINED.
listId
string
Members of this list (subscribed or pending).
subscriptionStatus
string
With listId: only PENDING, SUBSCRIBED or UNSUBSCRIBED members.
segmentId
string
Contacts matching this segment.
page
integer
Page number, from 0.
size
integer
Contacts per page: 50 by default, 200 at most.
curl "https://api.kulamail.com/v1/contacts?listId=5a8e…&size=100" \
  -H "Authorization: Bearer ml_your_api_key"
200 OK
{
  "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.

email
string required
The address. For a new contact, its domain must be able to receive email.
firstName
string
120 characters at most. Left out: unchanged. Empty string: cleared.
lastName
string
Same rules as firstName.
attributes
object
Merged into the stored attributes. See Attributes.
listIds
string[]
Lists to subscribe the contact to. Only send lists the person agreed to join.
doubleOptIn
boolean
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"]
  }'

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.

StatusCodeWhen
400validation_error, bad_requestInvalid address, attributes, or a domain that cannot receive email
404not_foundA list in listIds does not exist
409contact_unreachableSubscribing 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"
200 OK
{
  "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.

firstName
string
120 characters at most.
lastName
string
120 characters at most.
attributes
object
Merged into the stored attributes; 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.

erase
boolean
Query parameter. 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.

listId
string required
The list.
doubleOptIn
boolean
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" }'
200 OK
{
  "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.subscribed webhook.
listId
string required
The list the contacts join.
contacts
object[] required
1 to 10,000 contacts, each with 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.