Segments
A saved filter over contacts, computed when it is read, that narrows an audience before a broadcast goes.
A segment is a set of rules, not a stored list. Membership is computed when you ask, so a segment is never stale and nothing has to run overnight to refresh it. Point a broadcast at one with segment_id and the send goes to the intersection of the audience and the rules.
A segment either belongs to an audience or to the workspace. One with an audience_id can ask about membership state and join dates; one without cannot, because outside an audience there is nothing for those questions to mean.
Create one
curl -X POST https://emails.sh/v1/segments \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Active pro users who never clicked",
"audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
"match": "all",
"rules": [
{ "field": "status", "op": "eq", "value": "subscribed" },
{ "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
{ "field": "tag", "op": "not_has", "value": "churned" },
{ "field": "clicked", "op": "never" }
]
}'{
"id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2",
"name": "Active pro users who never clicked",
"description": null,
"audience_id": "2a7f4b19-3c56-4e08-9d21-6b8e0f4a7c33",
"match": "all",
"rules": [
{ "field": "status", "op": "eq", "value": "subscribed" },
{ "field": "attribute", "op": "eq", "name": "plan", "value": "pro" },
{ "field": "tag", "op": "not_has", "value": "churned" },
{ "field": "clicked", "op": "never" }
],
"describes": "subscribed, plan is pro, not tagged churned, and never clicked",
"member_count": 412,
"counted_at": "2026-07-28T09:14:01.882Z",
"created_at": "2026-07-28T09:14:01.882Z"
}describes is the rule list as a sentence, generated from the rules rather than typed by anybody. Show it next to a segment in your own UI and a person can check the filter without reading JSON.
The rule grammar
match is all or any, up to 20 rules, and there is no nesting. That ceiling is not an oversight: a filter that needs a nested boolean is a query, and a query belongs in your own database where you can test it.
| field | Operators, and what the rule takes |
|---|---|
tag | has, not_has. value is the tag, compared without case. |
attribute | eq, ne, contains, starts_with, gt, lt, exists, not_exists. name is the attribute, value is what to compare it against, and exists and not_exists take no value. |
status | eq, ne. value is subscribed, unsubscribed, pending, or cleaned. Needs an audience_id. |
joined | before, after with an ISO date; within_days, not_within_days with a number from 1 to 3650. Needs an audience_id. |
opened | within_days, not_within_days with days from 1 to 3650; ever, never with no argument. |
clicked | The same four operators as opened. |
- attribute ne matches an absent attribute
- A contact with no plan attribute at all satisfies plan ne pro. That is usually what you want and occasionally a surprise, so pair it with an exists rule when it is not.
- gt and lt compare numbers only when both sides are numbers
- Attribute values are stored as text. If either side does not parse as a number the rule does not match, rather than falling back to comparing strings and quietly matching the wrong people.
- Rules are replaced, never merged
- PATCH takes the whole rules array and writes it over the old one. Send every rule you are keeping, not just the one you are changing.
- Opens undercount
- An opened rule reads tracking pixels, and image blocking means it is a floor rather than a count. never on opened matches people who read every message in a client that blocks images. clicked is the sturdier signal.
Count and read it
member_count comes back cached, with counted_at saying when it was computed. Pass count=live to recompute it now, which is what you want on a screen where somebody is about to press send.
# Recompute the count now
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2?count=live" \
-H "Authorization: Bearer $EMAILSSH_API_KEY"
# Who is in it, mailable only, one page at a time
curl "https://emails.sh/v1/segments/7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2/members?mailable=true&limit=500" \
-H "Authorization: Bearer $EMAILSSH_API_KEY"The members list is cursor paged: take next_after from a response and pass it as after on the next call. It stops when next_after comes back null. mailable=true drops anybody who is not subscribed or who is suppressed, which is the set a broadcast would actually reach.
Use it on a broadcast
curl -X PATCH https://emails.sh/v1/broadcasts/d1f6c48a-2b07-4e93-85ca-7f30b9d16e52 \
-H "Authorization: Bearer $EMAILSSH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"segment_id": "7e2b8f43-05c1-4a96-b3d7-9f4e61a0c8d2"}'The segment is evaluated when the send starts, not when you attach it. Somebody who stopped matching in between is not mailed, which is the behaviour you want from a rule like "not tagged churned".
/v1/segmentsSegments. ?audience_id= narrows to one audience.
/v1/segments{ name, audience_id?, match?, rules? } creates one.
/v1/segments/:id?count=live recomputes member_count instead of reading the cached one.
/v1/segments/:idRules are replaced wholesale, never merged.
/v1/segments/:idRemove a segment. Contacts are untouched.
/v1/segments/:id/members?mailable=true&limit=&after= over who it matches now.
segments.create
A saved filter over contacts. Membership is computed when it is read rather than stored, so a segment is never stale.
Arguments
namestringrequiredaudience_idstring- Leave it out for a workspace-wide segment. status and joined rules need one.
match"all" | "any"- Defaults to all.
rulesRule[]- Up to 20, and never nested.
descriptionstring
Returns{ id, name, describes, member_count }
segments.get
One segment, its rules, and the sentence describing them.
Arguments
idstringrequiredcountstring- Pass live to recompute member_count now instead of reading the cached one.
ReturnsSegment
segments.update
Change a segment. Send the whole rule list every time, including the rules you are keeping.
Arguments
idstringrequiredmatch"all" | "any"rulesRule[]- Replaced wholesale. Rules are never merged into what is there.
ReturnsSegment
segments.members
Who a segment currently matches, as a cursor-paged list.
Arguments
idstringrequiredmailableboolean- Only members who are subscribed and not suppressed.
limitnumber- Defaults to 100, maximum 1000.
afterstring- The next_after cursor from the previous page.
Returns{ segment_id, total, next_after, members[] }