Audiences and contacts
A named list with subscription state and per-list merge fields, and the import that fills it.
Most of what emails.sh sends is transactional: one email, caused by one thing a person did. An audience is the other case, a list of people you mail together: a product changelog, a release note, an occasional announcement.
You send to one with a broadcast, and you narrow one with a segment. Both of those are their own chapters: /docs/broadcasts and /docs/segments. This page is the list itself, who is on it, and what you know about each of them.
Create an audience
curl -X POST https://emails.sh/v1/audiences \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Product updates", "description": "Monthly changelog"}'Membership, not contact
Two ids matter here and confusing them is the most common mistake against this API. A contact is a person on the workspace and has a contact_id. A membership is that person on one list, and it has its own id. Every route under /v1/audiences/:id/contacts/:member takes the membership id, and nothing there accepts a contact id or an email address.
{
"contact_count": 2,
"subscribed_count": 1,
"contacts": [
{
"id": "6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480",
"contact_id": "b4c0e21f-7d69-4a55-8e13-2f9a6c081d77",
"email": "ada@example.com",
"status": "subscribed",
"attributes": { "first_name": "Ada", "plan": "pro" },
"subscribed_at": "2026-07-02T11:20:04.118Z",
"unsubscribed_at": null
},
{
"id": "c8a95b70-1e34-4d29-b6f5-0a72e4c31d96",
"contact_id": "5d3f8a12-6b04-47ce-9a81-cf20e5b7a344",
"email": "grace@example.com",
"status": "unsubscribed",
"attributes": { "first_name": "Grace" },
"subscribed_at": "2026-06-14T08:02:51.663Z",
"unsubscribed_at": "2026-07-19T16:45:12.907Z"
}
]
}Import
POST /v1/audiences/:id/contacts is a bulk import rather than a single add. It takes a bare array, a { "contacts": [] } envelope, a { "data": [] } envelope, or one object, and it takes CSV when the Content-Type says csv. Each row is an email, optional attributes, an optional status, and optional tags. Up to 10000 rows and 8 MB per call, past which you get 413 too_many_contacts or 413 import_too_large.
curl -X POST https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{
"email": "ada@example.com",
"attributes": { "first_name": "Ada", "plan": "pro" },
"status": "subscribed",
"tags": ["beta"]
},
{
"email": "grace@example.com",
"attributes": { "first_name": "Grace", "plan": "free" },
"tags": ["beta", "waitlist"]
}
]
}'{
"created": 1,
"updated": 1,
"unchanged": 0,
"duplicates": 0,
"tags_added": 3,
"imported": 2,
"held_back": 0,
"skipped": 0,
"held": [],
"errors": []
}created counts memberships this call actually wrote, so it is the number of people the list gained. A row naming somebody who is already a member is counted in duplicates instead, and nothing is written for it: that is what a second spelling of one address in the same file looks like, and it is how you can tell a re-upload added nobody rather than trusting that it did not.
An address already on the suppression list is written as cleaned rather than subscribed and counted in held_back, whatever status the row asked for. That is the whole point: an import cannot resurrect somebody who bounced or complained, and the count tells you it happened instead of hiding it.
Add dry_run=true to the query string to find out what would happen without writing anything. It answers 200 with would_create, would_update, would_leave_unchanged, would_hold_back, the columns it found, and a sample of parsed rows, which is what you want in front of a person before importing a CSV somebody exported from a spreadsheet.
# A CSV whose columns are not our names. Map them in the query string.
curl -X POST "https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts?dry_run=true&mapping.email=Email%20Address&mapping.attr.First%20Name=first_name&mapping.ignore=Internal%20Notes" \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: text/csv" \
--data-binary @subscribers.csv- mapping.email
- Which column holds the address. Required when no column is called email or
email_address. - mapping.status
- Which column holds the subscription state.
- mapping.tags
- Which column holds tags, as a comma-separated string.
- mapping.attr.<column>
- Store that column as an attribute under the name you give. Repeat it once per column.
- mapping.ignore
- Drop a column entirely. Repeat it once per column.
Attributes are merge fields here
The attributes on a membership are per-list merge fields, and they are what a broadcast substitutes into {{ first_name }}. They are a different store from the workspace-level contact attributes at /v1/contacts/:id/attributes, which is what segments and automations read. /docs/contact-data is the chapter about the difference, and it is worth reading before you decide where to put a field.
Unsubscribes
Record an unsubscribe rather than deleting the membership. A deleted row can be re-added by the next import and mailed again, which is how a company ends up in a complaint report. A membership set to unsubscribed stays unsubscribed, and DELETE on a membership records nothing.
curl -X PATCH https://emails.sh/v1/audiences/2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33/contacts/6f1d2c33-8a4e-4b17-9f02-51c7d9a3e480 \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"subscribed": false}'A broadcast filed under a topic gets its List-Unsubscribe header and its one-click opt-out built for it. If you are sending to an audience some other way, put the header on yourself: Gmail and Yahoo both require one-click unsubscribe on bulk mail, and the alternative to a working link is the spam button.
Statuses
| Status | What it means |
|---|---|
subscribed | Mailable. The only status a broadcast sends to. |
pending | Added under double opt-in and has not confirmed yet. Not mailed except by the confirmation itself. See /docs/double-opt-in. |
unsubscribed | They asked to stop. Kept as a row so a later import cannot undo it. |
cleaned | The address bounced, complained, or was already suppressed when it was imported. Not mailable, and not something an import can change back. |
/v1/audiencesAudiences on the workspace.
/v1/audiences{ name, description? } creates one.
/v1/audiences/:idOne audience, with its double opt-in setting and where that setting came from.
/v1/audiences/:id{ name?, description?, require_double_opt_in?, confirmation_subject?, confirmation_body? }
/v1/audiences/:idSoft delete an audience.
/v1/audiences/:id/contactsMembers. ?status=&limit=&offset=. id on each row is the membership id.
/v1/audiences/:id/contactsBulk import of { email, attributes?, status?, tags? } rows, as JSON or CSV. ?dry_run=true reports without writing.
/v1/audiences/:id/contacts/:memberOne membership, by membership id.
/v1/audiences/:id/contacts/:member{ attributes?, subscribed?, status? } on one membership.
/v1/audiences/:id/contacts/:memberRemove one membership. It records no unsubscribe.
/v1/audiences/:id/contacts/:member/confirmSend the double opt-in confirmation email. Nothing else ever sends it.
audiences.list
Audiences on the workspace. An audience is a named list of contacts with their subscription state.
Takes no arguments.
Returns{ audiences: Audience[] }
audiences.create
Create an audience.
Arguments
namestringrequired
Returns{ id, name }
audiences.members
Members of one audience. Each row carries a membership id, which is the id every other member route takes, and a contact_id, which is the workspace-level contact behind it.
Arguments
audience_idstringrequiredstatusstring- subscribed, unsubscribed, pending, or cleaned.
limitnumber- Defaults to 100, maximum 1000.
offsetnumber
Returns{ contact_count, subscribed_count, contacts: Member[] }
audiences.import
Bulk import into an audience. Up to 10000 rows and 8 MB per call. An address already on the suppression list lands as cleaned rather than subscribed and is counted in held_back.
Arguments
audience_idstringrequiredcontacts{ email, attributes?, status?, tags? }[]required- Rows to import. A bare array, { contacts: [] }, { data: [] }, or one object all work, and CSV works when the Content-Type says csv.
dry_runboolean- Query parameter. Reports what would happen and writes nothing.
Returns{ created, updated, unchanged, duplicates, tags_added, imported, held_back, skipped, held[], errors[] }
audiences.updateMember
Change one membership. Recording an unsubscribe here is what keeps the address from being mailed by the next broadcast.
Arguments
audience_idstringrequiredmemberstringrequired- The membership id from audiences.members. Not a contact id and not an email address.
attributesRecord<string, string>- Per-list merge fields for this membership.
subscribedboolean- Wins over status when both are given.
statusstring- subscribed, unsubscribed, pending, or cleaned.
ReturnsMember
audiences.removeMember
Remove one membership from an audience. It does not delete the contact, and it does not record an unsubscribe: set status to unsubscribed for that.
Arguments
audience_idstringrequiredmemberstringrequired- The membership id.
Returns{ deleted: id }