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

POST /v1/segments
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" }
    ]
  }'
201 Created
{
  "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.

fieldOperators, and what the rule takes
taghas, not_has. value is the tag, compared without case.
attributeeq, 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.
statuseq, ne. value is subscribed, unsubscribed, pending, or cleaned. Needs an audience_id.
joinedbefore, after with an ISO date; within_days, not_within_days with a number from 1 to 3650. Needs an audience_id.
openedwithin_days, not_within_days with days from 1 to 3650; ever, never with no argument.
clickedThe 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.

Count, then list
# 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

Narrow a draft to a segment
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".

GET /v1/segments

Segments. ?audience_id= narrows to one audience.

POST /v1/segments

{ name, audience_id?, match?, rules? } creates one.

GET /v1/segments/:id

?count=live recomputes member_count instead of reading the cached one.

PATCH /v1/segments/:id

Rules are replaced wholesale, never merged.

DELETE /v1/segments/:id

Remove a segment. Contacts are untouched.

GET /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

name string required
audience_id string
Leave it out for a workspace-wide segment. status and joined rules need one.
match "all" | "any"
Defaults to all.
rules Rule[]
Up to 20, and never nested.
description string

Returns{ id, name, describes, member_count }

segments.get

One segment, its rules, and the sentence describing them.

Arguments

id string required
count string
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

id string required
match "all" | "any"
rules Rule[]
Replaced wholesale. Rules are never merged into what is there.

ReturnsSegment

segments.members

Who a segment currently matches, as a cursor-paged list.

Arguments

id string required
mailable boolean
Only members who are subscribed and not suppressed.
limit number
Defaults to 100, maximum 1000.
after string
The next_after cursor from the previous page.

Returns{ segment_id, total, next_after, members[] }