Audiences + CDP
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
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
Campaigns target an audience — see Campaigns.
Automation hangs off the
segment.enteredandsegment.exitedevents 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 |
|---|---|---|
|
| Equality, with mild coercion: |
|
| Ordering. Both operands are read as numbers, or as epoch millis when either side looks like an ISO date ( |
|
| Set membership; |
|
| Case-insensitive substring on a string field. Against |
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,
eqandcontainsboth mean "carries all of these",inmeans "carries any of these", andgt/ltrefuse 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 |
|---|---|---|
|
| Any custom contact attribute — whatever you've set via |
|
| Built-in contact fields. |
|
| Lifecycle timestamps. Compare against an absolute ISO date. |
|
| Contact tags. Set semantics — see the callout above. |
|
| Suppression and merge state. Useful with |
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.*), nowindowparameter, no conversation roll-ups (conversations.openCount), nochannelslist, and no billing fields (subscription.plan,subscription.mrr). The builder's dropdown also offerslastEvent.nameandlastEvent.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 oncdp-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 |
|---|---|---|
|
| Required, and it must be a real UUID v7 contact id — not an opaque handle. Look it up via |
|
| Required, 1–120 characters. |
|
| Optional, defaults to |
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. CallChatly('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:
Load every
audiencesrow for the workspace.Scan
contacts, newest first, up to a 50,000-row ceiling. Where the filter requires tags, the scan is narrowed by atags @> …containment query against the GIN index first.Evaluate the filter conditions in the worker, one contact at a time.
Diff against the current
audience_membersrows.Delete and re-insert to reconcile.
Publish
segment.entered/segment.exitedfor the differences.
Parameters
Name | Type | Description |
|---|---|---|
|
| 1 hour. There is no per-audience schedule and no realtime mode — a |
|
| Newest contacts first. A workspace above the ceiling gets a truncated — and therefore incomplete — audience, and the shortfall is logged. Default: |
|
| 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: |
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/tagsor 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/ltneed an absolute value. An ISO instant (2026-01-31T00:00:00Z) or date (2026-01-31) works; an ISO 8601 duration likeP7Dis 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.