curl --request POST \
--url https://app.lettr.com/api/audience/contacts/bulk \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"emails": [
"jane@example.com",
"joe@example.com"
],
"list_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"properties": {},
"contacts": [
{
"email": "jane@example.com",
"properties": {
"first_name": "Jane"
},
"list_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
]
}
],
"list_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
],
"update_existing": false
}
'import requests
url = "https://app.lettr.com/api/audience/contacts/bulk"
payload = {
"emails": ["jane@example.com", "joe@example.com"],
"list_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"properties": {},
"contacts": [
{
"email": "jane@example.com",
"properties": { "first_name": "Jane" },
"list_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
]
}
],
"list_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
],
"update_existing": False
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
emails: ['jane@example.com', 'joe@example.com'],
list_id: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
properties: {},
contacts: [
{
email: 'jane@example.com',
properties: {first_name: 'Jane'},
list_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a'],
topics: [{id: '3c90c3cc-0d44-4b50-8888-8dd25736052a', subscription: 'opt_in'}]
}
],
list_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a'],
topics: [{id: '3c90c3cc-0d44-4b50-8888-8dd25736052a', subscription: 'opt_in'}],
update_existing: false
})
};
fetch('https://app.lettr.com/api/audience/contacts/bulk', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.lettr.com/api/audience/contacts/bulk",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'emails' => [
'jane@example.com',
'joe@example.com'
],
'list_id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'properties' => [
],
'contacts' => [
[
'email' => 'jane@example.com',
'properties' => [
'first_name' => 'Jane'
],
'list_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
],
'topics' => [
[
'id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'subscription' => 'opt_in'
]
]
]
],
'list_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
],
'topics' => [
[
'id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'subscription' => 'opt_in'
]
],
'update_existing' => false
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.lettr.com/api/audience/contacts/bulk"
payload := strings.NewReader("{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.lettr.com/api/audience/contacts/bulk")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.lettr.com/api/audience/contacts/bulk")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}"
response = http.request(request)
puts response.read_body{
"message": "Contacts created successfully.",
"data": {
"created": 2,
"already_existed": 0,
"updated": 0,
"error_count": 0,
"errors": [
{
"index": 1,
"email": "<string>",
"error_code": "missing_email",
"error": "<string>"
}
],
"contacts": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"email": "jsmith@example.com",
"created": true
}
]
}
}Bulk create contacts
Create up to 1000 contacts in a single request. Send contacts to give each row its own properties, lists and topics, or the legacy emails array when the whole batch shares one property map. Emails are normalized and deduplicated; addresses that already exist are reported in already_existed, are always attached to the requested lists and topics and always have a row-level opt_out applied, and have their properties merged only when update_existing is set. Rows that fail validation are skipped and returned in errors — the request only fails with 422 when nothing could be written. The response carries the contact ids, so a follow-up call to the bulk list or topic endpoints needs no lookup. Requires the audience:write scope and is blocked for sandbox API keys.
curl --request POST \
--url https://app.lettr.com/api/audience/contacts/bulk \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '
{
"emails": [
"jane@example.com",
"joe@example.com"
],
"list_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"properties": {},
"contacts": [
{
"email": "jane@example.com",
"properties": {
"first_name": "Jane"
},
"list_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
]
}
],
"list_ids": [
"3c90c3cc-0d44-4b50-8888-8dd25736052a"
],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
],
"update_existing": false
}
'import requests
url = "https://app.lettr.com/api/audience/contacts/bulk"
payload = {
"emails": ["jane@example.com", "joe@example.com"],
"list_id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"properties": {},
"contacts": [
{
"email": "jane@example.com",
"properties": { "first_name": "Jane" },
"list_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
]
}
],
"list_ids": ["3c90c3cc-0d44-4b50-8888-8dd25736052a"],
"topics": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"subscription": "opt_in"
}
],
"update_existing": False
}
headers = {
"Authorization": "Bearer <token>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Bearer <token>', 'Content-Type': 'application/json'},
body: JSON.stringify({
emails: ['jane@example.com', 'joe@example.com'],
list_id: '3c90c3cc-0d44-4b50-8888-8dd25736052a',
properties: {},
contacts: [
{
email: 'jane@example.com',
properties: {first_name: 'Jane'},
list_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a'],
topics: [{id: '3c90c3cc-0d44-4b50-8888-8dd25736052a', subscription: 'opt_in'}]
}
],
list_ids: ['3c90c3cc-0d44-4b50-8888-8dd25736052a'],
topics: [{id: '3c90c3cc-0d44-4b50-8888-8dd25736052a', subscription: 'opt_in'}],
update_existing: false
})
};
fetch('https://app.lettr.com/api/audience/contacts/bulk', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://app.lettr.com/api/audience/contacts/bulk",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'emails' => [
'jane@example.com',
'joe@example.com'
],
'list_id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'properties' => [
],
'contacts' => [
[
'email' => 'jane@example.com',
'properties' => [
'first_name' => 'Jane'
],
'list_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
],
'topics' => [
[
'id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'subscription' => 'opt_in'
]
]
]
],
'list_ids' => [
'3c90c3cc-0d44-4b50-8888-8dd25736052a'
],
'topics' => [
[
'id' => '3c90c3cc-0d44-4b50-8888-8dd25736052a',
'subscription' => 'opt_in'
]
],
'update_existing' => false
]),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer <token>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://app.lettr.com/api/audience/contacts/bulk"
payload := strings.NewReader("{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Authorization", "Bearer <token>")
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://app.lettr.com/api/audience/contacts/bulk")
.header("Authorization", "Bearer <token>")
.header("Content-Type", "application/json")
.body("{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://app.lettr.com/api/audience/contacts/bulk")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Bearer <token>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"emails\": [\n \"jane@example.com\",\n \"joe@example.com\"\n ],\n \"list_id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"properties\": {},\n \"contacts\": [\n {\n \"email\": \"jane@example.com\",\n \"properties\": {\n \"first_name\": \"Jane\"\n },\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ]\n }\n ],\n \"list_ids\": [\n \"3c90c3cc-0d44-4b50-8888-8dd25736052a\"\n ],\n \"topics\": [\n {\n \"id\": \"3c90c3cc-0d44-4b50-8888-8dd25736052a\",\n \"subscription\": \"opt_in\"\n }\n ],\n \"update_existing\": false\n}"
response = http.request(request)
puts response.read_body{
"message": "Contacts created successfully.",
"data": {
"created": 2,
"already_existed": 0,
"updated": 0,
"error_count": 0,
"errors": [
{
"index": 1,
"email": "<string>",
"error_code": "missing_email",
"error": "<string>"
}
],
"contacts": [
{
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
"email": "jsmith@example.com",
"created": true
}
]
}
}audience:write scope and is blocked for sandbox API keys.
Send either emails (a flat list of addresses that all share the batch-wide list_ids, properties and topics) or contacts (one row per contact, each with its own properties, lists and topic subscriptions). Row-level values are applied on top of the batch-wide ones: a row’s properties key overrides the batch-wide value for that key, and a row-level opt_out beats a batch-level opt_in.
201 does not mean every row landed. Rows that fail validation are skipped and reported in errors — the rest of the batch still commits. Always check error_count / errors rather than treating a 2xx as full success.already_existed and updated overlap by design. They answer different questions — “was the address already in the audience?” versus “did this request change the contact?” — so they do not sum to the number of submitted rows. A contact that already existed and got attached to a list is counted in both.update_existing governs property merges and nothing else. By default (update_existing: false) existing contacts keep their properties; set it to true to merge instead — submitted keys overwrite, absent keys are preserved.
Lists, topic opt-ins and topic opt_out entries are applied either way. A row-level opt_out drops an existing subscription whether or not the flag is set, so a consent or suppression import does not need it.
The contacts array in the response returns { id, email, created } for every contact that exists after the request, in submission order. Feed those ids straight into bulk attach contacts to lists or bulk subscribe contacts to topics without a follow-up lookup.
Limits
| Limit | Value |
|---|---|
Rows per batch (emails or contacts) | 1000 |
Batch-wide list_ids | 50 |
Batch-wide topics | 50 |
| Property value length | 1000 characters |
Row error codes
Each entry inerrors carries the zero-based index of the submitted row, the email, a human-readable error, and one of these error_code values:
| Code | Meaning |
|---|---|
missing_email | The row had no email address. |
invalid_email | The address failed validation. |
invalid_property_value | A property value was the wrong type or too long. |
unknown_property_key | A property key is not defined for the team. |
unknown_list | A list id does not exist. |
unknown_topic | A topic id does not exist. |
invalid_topic_subscription | A subscription value was neither opt_in nor opt_out. |
Authorizations
API key for authentication
Body
- Option 1
- Option 2
Bulk create contacts, up to 1000 per request. Send either emails (one shared property map for the whole batch) or contacts (a row per contact, each with its own properties, lists and topics) — not both. Emails are normalized (lowercased, trimmed) and deduplicated, with the last row for an address winning. Batch-wide list_ids and topics are unioned into every row.
Legacy shape: a flat list of addresses that all share properties and list_id.
1 - 1000 elements255["jane@example.com", "joe@example.com"]
Optional list to add all contacts to. Kept for backwards compatibility; folded into list_ids.
Custom property values applied to every contact in the batch. Works with both shapes; with contacts, a key set on a row overrides the batch value for that key.
Show child attributes
Show child attributes
One row per contact. A row that fails validation is skipped and reported in the response errors array — the rest of the batch is still written.
1 - 1000 elementsShow child attributes
Show child attributes
Lists every contact in the batch is added to.
50Topic subscriptions applied to every contact in the batch. A row-level opt_out wins over a batch-level opt_in for that contact.
50Show child attributes
Show child attributes
When true, contacts that already exist have their properties merged — submitted keys overwrite, others are preserved. When false their properties are left alone and they are counted in already_existed. This flag governs property merges only: lists, topic opt-ins and topic opt_out entries are always applied, so a row-level opt_out drops an existing subscription whether or not the flag is set.
Was this page helpful?