Segments
A segment is a named audience — a list of contacts a campaign can be aimed at. Unlike contacts, which are global, segments are tenant-scoped: a segment is a view over one workspace's contact book.
Shape
InboxSegment:
| Field | Notes |
|---|---|
tenantId |
Scoped, with @@unique([tenantId, name]) — one name per workspace |
name, description |
|
status |
active | archived. Defaults to active |
legacySegmentId |
Provenance from the axis-api import |
InboxSegmentMember:
| Field | Notes |
|---|---|
segmentId, contactId |
@@unique([segmentId, contactId]) — membership is idempotent by construction |
source |
import | manual | rule | api — how they joined |
addedAt |
Both directions are indexed on purpose. "Who is in this segment" builds an audience; "what segments is this person in" is what a campaign builder asks per contact and what an operator sees on a contact page. At 461,090 memberships across 1,021 segments — roughly 450 each with a long tail — missing either index turns a routine screen into a sequential scan.
Archive, not delete
DELETE /v1/segments/:id returns 200 with the archived row, not 204. A campaign points at
segmentId with onDelete: SetNull, so actually removing the row would erase the only record of who a
completed send went to. The verb stays DELETE because that is what the operator means and what the
delete button issues; the response body is what tells them the segment was retired rather than
destroyed.
Routes
All are @RequireScopes('inbox') — the same scope as contacts, because a segment grants nothing a
contact-capable key cannot already reach. Every write needs an Idempotency-Key.
| Method | Path | Status | Notes |
|---|---|---|---|
| GET | /v1/segments |
200 | status (active by default), q, limit, cursor, includeTotal |
| POST | /v1/segments |
201 | Body { name?, description?, legacySegmentId? } |
| GET | /v1/segments/:id |
200 | includeMembers, membersLimit, membersCursor |
| PATCH | /v1/segments/:id |
200 | Body { name?, description?, status? } |
| DELETE | /v1/segments/:id |
200 | Archives |
| GET | /v1/segments/:id/members |
200 | limit, cursor |
| POST | /v1/segments/:id/members |
200 | Bulk add. Body { contactIds, source? } |
| DELETE | /v1/segments/:id/members |
200 | Bulk remove. Body { contactIds } |
Plus the reverse lookup:
| Method | Path | Notes |
|---|---|---|
| GET | /v1/contacts/:contactId/segments |
Unpaged — a person belongs to a handful of segments, and this renders as a row of chips |
That route lives in the segments module, not on the contacts controller, purely for dependency direction: segments already depend on contacts for the tenancy check, so hosting it on the contacts side would close a module cycle. Nest routes by path, so the URL is still contact-shaped for you.
Two shapes worth noting:
?includeMembers=truereturns{ segment, members, membersNextCursor }in one round-trip instead of a get plus a listMembers.- Bulk add returns 200, not 201, and reports
addedalongside the resultingmemberCount, so you can tell "40 were new" from "all 100 were already in". A replay that creates nothing has not created a resource. - Bulk remove is a DELETE with a body. The id list can be a thousand long, so it cannot be a query string.
The shapes are loosely typed on purpose
Segment request and response bodies are Record<string, unknown> at the service boundary, and there
are no segment DTOs in @~inbox/types — unlike threads, entries, tickets and contacts, which all
have published interfaces. Do not import a SegmentDto; it does not exist. Read the fields listed
above from the JSON, and treat the surface as less stable than the typed domains.
Feeding campaigns
A campaign carries a segmentId. At send time the engine expands the segment into recipient rows and
then runs each one through the eligibility check in Consent and suppression —
segment membership decides who is targeted, consent decides who is sent to, and the difference is
recorded per recipient as a skip reason rather than silently dropped.
POST /v1/contacts/import accepts a segmentId so an imported CSV lands as a ready audience in one
call, and POST /v1/contacts/upsert sets membership alongside the contact and its identifiers.