Guides

Audiences + CDP

AdminUpdated Sep 19, 2026

Audiences + CDP

Saved contact segments, re-evaluated on a schedule — the foundation of campaigns and segment-driven automation.

An audience is a saved query against your contacts. Once you define one, a background sweep re-evaluates it and reconciles the membership list, so a campaign always sends to the cohort as it stands rather than to a list somebody exported last month.

Info — Two places audiences show up

  1. Campaigns target an audience — see Campaigns.

  2. Automation hangs off the segment.entered and segment.exited events the sweep publishes, so a trigger or workflow can react the moment somebody joins or leaves.

Define an audience

Dashboard

Audiences → New. The builder is a row-per-condition filter: pick a field, pick an operator, type a value. Every condition must match (boolean AND).

WHERE
  attributes.plan = "business"
AND
  attributes.role = "admin"

API

POST /v1/cdp/audiences — Bearer token

{
  "name": "Business plan admins",
  "kind": "dynamic",
  "description": "Optional, up to 800 characters.",
  "filters": {
    "conditions": [
      { "field": "attributes.plan", "op": "eq", "value": "business" },
      { "field": "attributes.role", "op": "eq", "value": "admin" }
    ]
  }
}

GET /v1/cdp/audiences — Bearer token

Cursor-paginated, with ?sort= over id, name, kind, createdAt, updatedAt and ?filter[]= on name and kind.

Warning — Create and list are the whole API surface

There is no update, no delete, and no membership endpoint. Audiences live under /v1/cdp/audiences — not /v1/audiences — and both routes require a signed-in user; a workspace API key (ck_…) is refused with a 403.

Operators

Supported operators

Name

Type

Description

eq, neq

op

Equality, with mild coercion: "5" and 5 compare equal, and a timestamp compares against an ISO date string. Strings are case-sensitive. neq is satisfied by absence — a contact with no plan attribute genuinely is not on the pro plan.

gt, lt

op

Ordering. Both operands are read as numbers, or as epoch millis when either side looks like an ISO date (2026-01-31 or 2026-01-31T09:00:00Z). Relative durations are not supportedP7D is neither, and a condition using one matches nobody.

in

op

Set membership; value is an array.

contains

op

Case-insensitive substring on a string field. Against tags it means "carries all of these".

Those six — eq, neq, gt, lt, in, contains — are what the create endpoint accepts. There is no gte/lte, no not_in, no not_contains, no matches, no exists, no between.

Info — Tags behave as a set

When the field resolves to an array, eq and contains both mean "carries all of these", in means "carries any of these", and gt/lt refuse rather than guess. Tag matching is case-sensitive, because that is how tags are stored.

Available fields

Conditions are evaluated against the contact row. Anything not on a contact is not filterable.

Filter fields

Name

Type

Description

attributes.*

any

Any custom contact attribute — whatever you've set via identify(...) or the contacts API. This is where most audiences live.

email, phone, name, locale, timezone, externalId

string

Built-in contact fields.

createdAt, updatedAt, firstSeenAt, lastSeenAt

timestamp

Lifecycle timestamps. Compare against an absolute ISO date.

tags

string[]

Contact tags. Set semantics — see the callout above.

unsubscribedAt, blockedAt, mergedIntoId

timestamp / uuid

Suppression and merge state. Useful with neq to exclude.

Warning — Not filterable, despite what the field picker suggests

The sweep reads contacts and nothing else. There are no CDP-event fields (events.{name}.count, .lastAt, .props.*), no window parameter, no conversation roll-ups (conversations.openCount), no channels list, and no billing fields (subscription.plan, subscription.mrr). The builder's dropdown also offers lastEvent.name and lastEvent.properties.value, which the evaluator does not hydrate — a condition on either matches nobody. Filter on a contact attribute you write yourself instead, and keep event-shaped logic in a trigger on cdp-driven events.

CDP events

CDP events are stored per contact and are what the contact timeline, the live-visitor list and the ongoing-campaign page trigger read. They are not an audience filter input.

From your server

POST /v1/cdp/events — Bearer token

{
  "contactId": "0190f4a2-8c31-7a55-9f0e-2d1b6c4e77aa",
  "name": "subscription.upgraded",
  "properties": { "from": "starter", "to": "business", "mrr": 199 }
}

Parameters

Name

Type

Description

contactId

uuid

Required, and it must be a real UUID v7 contact id — not an opaque handle. Look it up via GET /v1/contacts first.

name

string

Required, 1–120 characters.

properties

object

Optional, defaults to {}. The field is properties, not props.

This is one of the few routes that does accept a workspace API key (ck_…) — server-side ingestion is the reason keys exist. One event per request; there is no batch endpoint and the schema does not accept an array.

GET /v1/cdp/events — Bearer token

Cursor-paginated, ?contactId= to scope to one contact, with ?filter[]= on contactId, name, occurredAt and createdAt. This one is session-only.

From the widget

Chatly('track', 'checkout.completed', {
  orderId: 'O-7421',
  amount: 199.0,
  currency: 'USD',
});

Warning — track() is a no-op for anonymous visitors

An event needs a contact to attach to. Until the visitor is known, track() is accepted and discarded rather than throwing. Call Chatly('identify', …) first if you need the event recorded.

Forwarding out

Chatly can push CDP events outward to Segment, Mixpanel, Amplitude or Google Analytics. Connect the provider under Integrations, then use the track_event action in a trigger or workflow — that handler writes the cdp_events row and forwards a copy to every connected analytics product. Each forward is independent and best-effort; the stored row is never lost because a destination is down.

This is one-directional. There is no Chatly destination to install inside Segment or RudderStack, and nothing pulls events in from PostHog — inbound events arrive through the endpoint and the widget above.

Evaluation

A background sweep runs hourly, per workspace:

  1. Load every audiences row for the workspace.

  2. Scan contacts, newest first, up to a 50,000-row ceiling. Where the filter requires tags, the scan is narrowed by a tags @> … containment query against the GIN index first.

  3. Evaluate the filter conditions in the worker, one contact at a time.

  4. Diff against the current audience_members rows.

  5. Delete and re-insert to reconcile.

  6. Publish segment.entered / segment.exited for the differences.

Parameters

Name

Type

Description

Sweep interval

fixed

1 hour. There is no per-audience schedule and no realtime mode — a realtime: true flag is not a field the API accepts.

Contact scan ceiling

fixed

Newest contacts first. A workspace above the ceiling gets a truncated — and therefore incomplete — audience, and the shortfall is logged. Default: 50,000.

Membership events per audience per run

fixed

Editing a filter can move tens of thousands of contacts at once; only the first 1,000 transitions are announced. The remainder are logged, not published. Default: 1,000.

The events are named segment.entered and segment.exited — not audience.entered / audience.left — and their payload carries contactId, segment_id and segment_name.

Warning — Campaigns re-evaluate at send time

A one-off campaign does not wait for the hourly sweep. When its scheduled time comes, the dispatcher runs the audience for that campaign and materialises the recipient list from the result, so the list is fresh even if the sweep last ran 59 minutes ago.

Dynamic vs static

kind is recorded on the audience and shown in the list, but the sweep does not branch on it: every audience — including one saved as static — has its membership recomputed from its filters on each run.

Warning — There is no way to pin a member list

No endpoint adds or removes members, and no CSV import exists. If you need a frozen cohort, give those contacts a distinctive tag or attribute (via POST /v1/contacts/:id/tags or a bulk import) and write an audience whose filter matches it. The membership is then as stable as the tag.

Why this matters

Audiences turn Chatly into a proactive product. Not "wait for the customer to ask" but "we know they're on the business plan and haven't been seen in three weeks — reach out." The same plumbing supports:

  • Onboarding nudges.

  • Churn-risk outreach.

  • Cross-sell to existing customers.

  • Reactivation of dormant accounts.

  • Segment-driven automation, via segment.entered / segment.exited.

Troubleshooting

Info — Audience count is zero

Most often you've referenced an attribute no contact carries yet — a typo, or a key you never actually set. Field names and string values are case-sensitive on eq. Check a real contact's attributes in the contact drawer and copy the key from there.

Warning — A date condition matches nobody

gt / lt need an absolute value. An ISO instant (2026-01-31T00:00:00Z) or date (2026-01-31) works; an ISO 8601 duration like P7D is not parsed as a moment in time and the condition silently fails. There is no relative-time operator — recompute the absolute date when you edit the audience, or store a "last active" bucket as an attribute.

Info — Membership updated late

The sweep is hourly, so an attribute you changed a minute ago will not be reflected until the next run. Campaign sends are the exception — they re-evaluate their own audience at send time.

Was this page helpful?