Tables and segments

Understand what one row means on each table, then save a filter as a named segment that re-runs live and share it with your team.

A segment is a saved live filter over one grain: companies, contacts, or persons.

storedactive paying companiesgrain: companystripe_status = activesc__engagement ≥ 40this is everything a segment storesruns as one queryon every readpiedpiper.comhooli.comsliceline.comaviato.comendframe.comraviga.comlive recordsmembersnot memberschanges feeda data change flips membership the moment it lands
One stored object: the definition. Members are whoever matches right now, computed on every read; the changes feed reports each enter and exit.

1. How segments work

  • What is stored: only the definition, a name, a grain, and a filter. Never a member list.
  • What runs: the filter compiles to one query over the grain's live columns; membership is whoever matches at the moment you read.
  • What that buys: a segment can never be stale, and editing the filter re-defines membership instantly, no rebuild.
  • What moves: when a record's data changes, its membership flips immediately, and each enter or exit becomes a row on the changes feed.

Segments are private to their creator by default, shareable explicitly, with one optional default view per grain.

2. Create a segment

A name, a grain, and a filter:

POST /observe/segments
{
  "name": "Active paying companies",
  "entityKind": "company",
  "filter": {
    "type": "group",
    "op": "and",
    "children": [
      { "type": "trait", "traitKey": "stripe_status", "operator": "eq", "value": "active" }
    ]
  }
}

In this definition:

  1. entityKind picks the grain; only name is strictly required.
  2. filter is a boolean group (and / or, nestable) over condition nodes.
  3. Conditions can reference any column the grain serves: fields, event counts, promoted event fields, identity links, scores.
  4. GET /observe/companies/fields is the catalog of what is filterable.

3. Example segments

SegmentGrainFilter reads
Active paying companiescompanystripe_status = active
Slack-active accountscompanyevc__slack__message ≥ 50
High engagementcompanysc__engagement ≥ 70
Champions we knowpersontitle contains founder, active affiliation
At-risk payerscompanypaying base segment, derived, sc__engagement < 40

Anything the grain serves as a column is filterable: fields, event counts, promoted event fields, identity links, scores.

4. Derive one grain from another

baseSegmentId builds a derived segment: start from a base on another grain, pivot through the affiliation edges, layer a filter on the result. "Contacts at active paying companies" is a contact segment derived from the company segment above.

Bases must be root segments; chains of derived segments are refused.

5. Share and set defaults

A segment belongs to the email that created it, stamped server-side. Shares grant view or edit per person; one deployment-wide default per grain defines the view everyone starts on. Service-key callers see everything.

6. Poll for changes

Because membership is live, "entered the segment" is a real signal. The consumer is one loop:

1Seed. Page GET /member-ids until hasMore is false; that list is your ledger.
2Poll. POST /changes?since=cursor returns member transitions with monotonic seq.
3Process. Enter, exit, re-enter, deduped per member id on your side.
4Advance. Save nextSince, wait, poll again.loop to 2
5Reconcile. 409 LOCK_BUSY: retry shortly. 410 CURSOR_EXPIRED or cursor_ahead: re-seed.back to 1
At-least-once by construction: the log is committed before you read it, so a crash re-reads instead of losing.

Seed with GET /observe/segments/{id}/member-ids, keyset-paged until hasMore is false.

Poll with POST /observe/segments/{id}/changes?since={cursor}. Each row is a member transition, enter, exit, or re-enter, with a monotonic seq; save nextSince after processing.

Delivery is at-least-once: the log commits before you read it, so a crashed poller re-reads instead of losing. Dedupe per member id on your side.

The edge cases are explicit, never silent:

  • The first call bootstraps and returns no rows; seed via member-ids.
  • A cursor ahead of the log or behind the prune watermark returns a reconcile signal (410 CURSOR_EXPIRED).
  • A concurrent poll is a 409. Derived segments have no feeds (400); poll their base.

Alerts, CRM sync, and downstream automation build on exactly this loop.