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:

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.

Related