Audience probes
Two POST endpoints that measure what a project's audience actually contains before you filter it: attribute coverage (fill rate, distinct values, most frequent values, numeric and date ranges) and event volumes (which event types arrive, when the last one arrived, and the real values their parameters carry).
A filter that selects nobody looks exactly like a filter that works. The attribute uid is real, the operator is real, the event type is real, the request returns 200, and the segment is empty. Nothing errors, because nothing is wrong: the field is simply filled on 4% of the audience, or the value you matched on is spelled churned in the data and inactive in your head.
Probes are the two endpoints that close that gap. They measure what the audience actually contains, attribute by attribute and event type by event type, so you compose a filter against the data that is there rather than against the schema that says it could be.
The two probes
Attribute coverage: which of the attributes this project declares are actually usable.
Event volumes: which event types this project actually receives, and what values their parameters carry.
Both take the PROJECT_AUDIENCE_READ scope, both accept an optional filter root to narrow what is being measured, and both sit in a restrictive rate-limit class whose ceiling follows your plan.
They are the companion of the specs endpoints: the attribute catalogue and the event catalogue answer what shape can this project hold. Probes answer what did it actually receive. An attribute that exists in the specs and is filled on 2% of contacts is present in the catalogue and absent in practice.
Probe before you filter
The working loop is the same on both sides, and it always ends on a count:
Probe
Ask coverage which attributes are filled and what values they hold, or ask volumes which event types arrive and what their parameters carry.
Read the real value
Take the literal string the data uses, not the one your product vocabulary uses.
Compose the filter
Write the audience query filter against that attribute and that value.
Count
Post it to
/audience/count. That number, and only that number, is the size you may act on or quote.
Attribute coverage
POST /project/{project}/audience/coverage returns one entry per attribute: how many contacts have it filled in, how many distinct values it holds, its most frequent values when it holds few enough of them to be a vocabulary rather than an identifier, and the range of a numeric or date field.
The request
Every key is optional. An empty body profiles the attributes worth profiling by default: enabled, visible, non-internal, and of a data type a measurement means something on. Identity attributes such as _email or _user_id are left out of that default, because a list of everyone's email addresses is not a vocabulary.
rootobjectAn audience query filter root, same grammar as /audience/search. Narrows the population being probed, so you can ask "what does this part of the audience contain". A call carrying a root is computed live and never served from cache.
datasourcestringA connector name, uid or id. Narrows the population to the contacts this data source contributed to. An unknown value is refused with the list of what exists.
attributesstring[]Attribute uids to probe. Naming one explicitly also measures attributes the default selection would skip, and overrides the name-based rule that withholds top values.
refreshbooleandefault: falseForce a recomputation. Throttled to one every five minutes; a denial is not an error.
curl -X POST "$BASE/audience/coverage" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "attributes": ["lifecycle_stage"] }'The response
Everything lives under metadata. The two blocks you read before any figure are denominator (the population the fill rates are over, and its definition) and sampling (how the figures were produced).
{
"metadata": {
"computedAt": "2026-08-28T09:12:04+00:00",
"denominator": {
"count": 48213,
"definition": "non-deleted contacts, no channel restriction"
},
"sampling": {
"applied": true,
"fraction": 0.5,
"buckets": "0-99",
"band": "20k-50k",
"scanned": 24106,
"scaled": true,
"minReliableCount": 6
},
"attributes": [
{
"uid": "lifecycle_stage",
"label": "Lifecycle stage",
"type": "keyword",
"multivalue": false,
"fillRate": 0.1204,
"filled": 5805,
"observedFilled": 2903,
"approximate": true,
"reliable": true,
"distinctValues": 3,
"topValues": [
{ "value": "churned", "count": 3120, "observedCount": 1560, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "paused", "count": 1660, "observedCount": 830, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "onboarding", "count": 1026, "observedCount": 513, "approximate": true, "reliable": true, "countError": 0 }
],
"topValuesOther": 0,
"summary": "12% of contacts have lifecycle_stage, estimated from a 50% sample"
}
],
"cached": true,
"computedAgo": "8 minutes",
"summary": "1 attributes profiled over 48213 contacts, 0 of them filled on at least half. Every figure here is estimated from a 50% sample and describes shape, never audience size."
}
}Reading an attribute entry
fillRatenumberThe fraction of the denominator that has this attribute filled in, rounded to four decimals. This is the number that decides whether the attribute is worth filtering on at all.
filledintegerfillRate projected onto the whole denominator. Under sampling this is an estimate, and it is still not an audience size: it is what the shape implies, not what a count returns.
observedFilledintegerThe raw number of contacts actually scanned that had it filled. Under sampling this is smaller than filled by the sampling fraction; without sampling the two agree.
approximatebooleanWhether the figures for this attribute came from a sample.
reliablebooleanWhether observedFilled cleared the detection floor (sampling.minReliableCount). false means the attribute is backed by too few observed contacts for the estimate to be trusted, not that it is empty. Always true when nothing was sampled, where a value is simply either there or not.
distinctValuesintegerEstimated number of distinct values. Published for every attribute measured, including the ones that get no top values, because "about forty thousand distinct values" is itself the answer to whether a field is a vocabulary or an identifier.
topValuesobject[]The ten most frequent values, each with a count (scaled to the denominator when sampling applies), the raw observedCount, its own approximate and reliable flags, and countError, the upper bound of how far the count could be off because of what other shards did not report.
topValuesOtherintegerContacts holding a value outside the ten listed. Above zero, the list is the most frequent values and not all of them.
topValuesOmittedstringPresent instead of topValues, saying why they were withheld. See The absence of top values is not an absence of values.
topValuesSemanticsstringPresent on multivalue attributes. The counts are contacts holding the value, and one contact can hold several, so they do not sum to the audience.
statsobjectNumeric and date ranges: avg, plus min and max when nothing was sampled. Under sampling the extremes are replaced by extremesOmitted.
summarystringThe whole entry as one sentence, with the sampling qualifier inside it rather than beside it. Quote this rather than reassembling your own sentence from the fields.
The envelope also carries cached and computedAgo (unfiltered profiles are cached, for longer on larger audiences), datasource when you narrowed by one, and truncated when the attribute cap was reached, naming the attributes left out.
Worked example: the filter that matched nobody
You want to reach contacts who went quiet. The project has a custom lifecycle_stage attribute, so the obvious filter is lifecycle_stage = inactive. It returns 200 and selects nobody, and nothing in the response explains why.
Probe first:
curl -X POST "$BASE/audience/coverage" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "attributes": ["lifecycle_stage"] }'The answer above says two things at once. The attribute is filled on 12% of contacts, so whatever you build on it addresses roughly an eighth of the audience at best. And its vocabulary is churned, paused, onboarding. There is no inactive, and there never was.
So the filter that matches is:
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "lifecycle_stage",
"operator": "matches-string",
"values": ["churned", "paused"]
}
]
}
}Post that to /audience/count for the number. The probe said churned is worth about 3,120 contacts and paused about 1,660; those are the shape talking. The count is the answer.
Event volumes
POST /project/{project}/event/volumes returns one entry per declared event type: how many arrived inside the window, whether anything arrived at all, and when the last one did. A type that is declared and never received comes back as an explicit zero, which the event catalogue cannot tell you, and that difference is what decides whether an event is worth building an automation or a filter on.
The request
rootobjectAn audience event query filter root. Narrows the events being probed. A call carrying a root is computed live and never cached.
datasourcestringA connector name, uid or id. Keeps events this data source produced.
windowintegerDays to look back. Omit for the default, which is wider on small audiences. Whatever results is clamped by the subscription.
eventstringOne event type uid. Adds a second block carrying the values that type's parameters actually hold.
refreshbooleandefault: falseForce a recomputation, throttled to one every five minutes.
curl -X POST "$BASE/event/volumes" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'The response
{
"metadata": {
"computedAt": "2026-08-28T09:20:11+00:00",
"window": {
"days": 15,
"requestedDays": 15,
"band": "20k-50k",
"clamped": false,
"clampedBy": null,
"from": "2026-08-13T09:20:11+00:00",
"to": "2026-08-28T09:20:11+00:00",
"anchor": "now"
},
"sampling": {
"applied": false,
"covers": "the per-type counts in this payload, nothing else",
"reason": "the type counts are never sampled: the aggregation is over about thirty distinct values and is cheap at any size",
"parameterValues": "sampled above 10,000 contacts; read event.sampling when you pass `event`"
},
"eventTypes": [
{
"uid": "ecommerce_order_create",
"label": "Order created",
"count": 12894,
"received": true,
"lastSeenAt": "2026-08-28T08:57:02+00:00",
"effectiveWindowDays": 15,
"windowLimitedByRetention": false
},
{
"uid": "ecommerce_checkout_abandon",
"label": "Checkout abandoned",
"count": 0,
"received": false,
"lastSeenAt": null,
"effectiveWindowDays": 15,
"windowLimitedByRetention": false
}
],
"cached": true,
"computedAgo": "2 minutes",
"summary": "1 of 2 declared event types received anything in the last 15 days. A zero means nothing arrived in this window, not that the type does not exist."
}
}The window block is the one to read: it reports the window that was actually queried, anchored to the instant of the call rather than to a calendar boundary.
The sampling block beside it says what it covers: the type counts only, and those genuinely are never sampled, because the aggregation runs over about thirty distinct values and costs the same whatever the document count. So applied: false is honest. It ships anyway, with covers and reason, so a consumer never has to infer the method from a missing key, and its parameterValues key points at the block that answers the other half of the question. That other half is The response carries two sampling blocks.
One level deeper: what the parameters carry
Pass event and the response gains an event block for that type, listing the real values of the parameters worth listing: product id and name, category, tags and vendor, the campaign id and name behind an attribution, its source and medium, statuses, methods. That is what turns "this project receives orders" into "this project sells these categories", which is the difference between guessing a filter value and reading one.
curl -X POST "$BASE/event/volumes" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "event": "ecommerce_order_create" }'{
"metadata": {
"event": {
"uid": "ecommerce_order_create",
"count": 12894,
"lastSeenAt": "2026-08-28T08:57:02+00:00",
"parameters": [
{
"uid": "product-id",
"values": [
{ "value": "WR3MTBK080", "count": 2140, "observedCount": 1070, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "A11113U001", "count": 1618, "observedCount": 809, "approximate": true, "reliable": true, "countError": 0 }
],
"other": 9136,
"truncated": true
},
{
"uid": "product-name",
"values": [
{ "value": "Men's Wool Runner, True Black", "count": 2140, "observedCount": 1070, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "Anytime No Show Sock 3-Pack", "count": 1618, "observedCount": 809, "approximate": true, "reliable": true, "countError": 0 }
],
"other": 9136,
"truncated": true
},
{
"uid": "product-category",
"values": [
{ "value": "Skincare", "count": 5210, "observedCount": 2605, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "Fragrance", "count": 3140, "observedCount": 1570, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "Haircare", "count": 1902, "observedCount": 951, "approximate": true, "reliable": true, "countError": 0 },
{ "value": "Gift sets", "count": 16, "observedCount": 8, "approximate": true, "reliable": false, "countError": 0 }
],
"other": 2626,
"truncated": true
}
],
"sampling": {
"applied": true,
"fraction": 0.5,
"buckets": "0-99",
"band": "20k-50k",
"scaled": true,
"minReliableCount": 6,
"unit": "contacts"
}
}
}
}Three things to note.
other above zero means those are the most frequent values, not all of them.
This block is sampled once the audience passes 10,000 contacts, and it carries its own sampling to say so. That is the block to read, not the top-level one: the example above was measured over half the audience, which is why every count is twice its observedCount and Gift sets comes back reliable: false. The rule and the fractions are in The response carries two sampling blocks.
And when it does sample, it samples by contact, never by event: a fraction of the people and all of their events. That is sound for which values occur and noisier for how many events there are, because one heavy buyer's whole history enters or leaves as a block. The unit: "contacts" field says so explicitly.
A parameter that carries no values but a valuesOmitted string could not be aggregated. An event type whose parameters are all unlisted returns parametersOmitted rather than an empty list, so "nothing worth listing by value" never reads as "this event carries nothing".
Worked example: from "does this arrive" to a count
You want to message people who bought skincare recently.
Is the event even arriving?
Post an empty body to
/event/volumes.ecommerce_order_createshows 12,894 in the window and alastSeenAtof this morning, so the type is live. Had it come backcount: 0with a nulllastSeenAt, the honest next step would be wideningwindow, not concluding the shop sells nothing.What are the real category names?
Probe again with
"event": "ecommerce_order_create".product-categoryholdsSkincare,Fragrance,Haircareand more (otheris above zero). The catalogue is capitalised and singular, so a filter written againstskincareorSkin carewould have matched nobody. Had you wanted one specific product rather than a category, the same answer carriesproduct-idnext toproduct-name: take the id for the condition and keep the name for the sentence you write around it.Compose the filter
Write a
group_eventcondition onecommerce_order_createwithproduct-categorymatchingSkincareover your timeframe. The grammar is on the audience query filter page.Count before you promise
Post it to
/audience/count. Expect a number lower than the 5,210 the probe reported: that figure counts orders, not people, it was itself scaled up from half the audience, and a segment evaluates a contact's own recent history rather than the full event index. The count is the size; the probe was only ever the way you found the value.
Rules that keep a probe honest
Ten things a response shape will not tell you. They are the difference between a probe that informs a decision and a probe that manufactures a confident wrong answer.
Read the window from the response, never from the request
window.days in the response is the window that was queried. It defaults wider on small audiences (90 days below 10,000 contacts, 30 up to 20,000, 15 above that), on the reasoning that the ambiguous zero hurts the low-volume account most. Whatever you ask for is then clamped by the subscription, and the clamp is reported rather than silently applied: clamped, clampedBy and requestedDays tell you it happened. Narrating "no purchases in the last 90 days" from a request that was served over 30 is the single easiest way to be precisely wrong.
A null lastSeenAt means nothing arrived in this window
It does not mean the type was never seen, and nothing in the response can tell the two apart. Distinguishing them would take a scan outside the window, which is exactly what this endpoint exists not to do. Widen window before concluding a type is unused. The same holds for count: 0 and received: false.
effectiveWindowDays is smaller than window.days when that event type is pruned sooner than the window reaches, and windowLimitedByRetention flags it. Read the effective figure, not the requested one.
datasource means two different things
The same key, on the two endpoints, answers two different questions on two different indexes:
| Endpoint | datasource selects |
|---|---|
/audience/coverage | contacts this source contributed to |
/event/volumes | events this source produced |
On coverage it is membership, never provenance. An audience contact is a consolidated projection of every source that touched it, so a value counted on a contact this source contributed to may perfectly well have been supplied by another one. The response restates this in datasource.meaning, because the wording is the only mitigation available: "of the contacts this source contributed to" is routinely heard as "this source supplies the value", and it does not.
The absence of top values is not an absence of values
When topValues is missing, topValuesOmitted says why, and none of the reasons is "empty":
- a known identity attribute, whose values are never listed;
- a name that reads as an identifier, a timestamp or a quantity;
- too many distinct values for a top-values list to mean anything;
- about one distinct value per contact that has it, so it reads as an identifier rather than a vocabulary;
- distinct values not measured.
The name-based skip is overridable: name the attribute explicitly in attributes and it is measured anyway. Asking for one by name is a decision, and the endpoint honours it.
min and max disappear under sampling
A sample extreme is biased low for a maximum and high for a minimum, and no threshold repairs that. Under sampling they are omitted and the omission is stated in stats.extremesOmitted. avg survives and is still published. A "highest spend" that is really the top of a 5% sample is a figure someone repeats in a meeting, so it is not published at all.
sampling is always present, applied: false included
A figure that does not carry its method gets quoted as exact. So the envelope ships on every response: applied: false still comes with fraction: 1 rather than leaving you to infer absence from a missing key. Below 10,000 contacts nothing is sampled whatever the audience band says. Above it, the fraction narrows as the audience grows, and every figure carries whether it is approximate (approximate) and whether it cleared the detection floor (reliable, against sampling.minReliableCount).
An unreliable figure is a figure backed by too few observed contacts, not a zero. Treat reliable: false as "ask again with a narrower population", never as "this value does not occur".
On /event/volumes there are two of these envelopes, and only the nested one describes parameter values. That is the next rule.
The response carries two sampling blocks
/event/volumes reports sampling twice, and reading the first as the whole story is the single easiest way to quote an estimate as an exact figure.
- The top-level
samplingdescribes the per-type counts, and says so in its owncoverskey. It is alwaysapplied: false, and that is honest: the aggregation runs over about thirty distinct values and costs the same whatever the document count, so there is nothing to sample. ItsparameterValueskey points at the other block. event.sampling, present only when you passedevent, describes the parameter values. Those are sampled once the audience passes 10,000 contacts.
The fraction comes from the audience band, the same table that sets the default window:
| Audience | Parameter values measured over |
|---|---|
| under 10,000 | the whole audience, nothing sampled |
| 10,000 to 20,000 | 75% |
| 20,000 to 50,000 | 50% |
| 50,000 to 200,000 | 20% |
| above 200,000 | 15% |
A floor of 15% applies on the event side, which is why the two largest bands land on the same figure: on the contact side the question is what shape the audience has, and a shape survives a thin sample, while here the question is which values occur, and a value carried by few contacts disappears from a thin sample without leaving a trace.
Under sampling every value carries observedCount, what was actually seen, beside the count scaled back up to the audience, plus its own approximate and reliable flags. reliable: false means the observed figure did not clear event.sampling.minReliableCount, the detection floor for that fraction.
refresh is throttled, and a denial is not an error
Unfiltered profiles are cached, and the cache lives longer on larger audiences. refresh: true forces a recomputation, limited to one every five minutes per project and surface. When the throttle denies it you still get a 200: the cached answer comes back carrying refreshDeclined: true and nextRefreshIn in words, so a client can act on it without interpreting an error. Check cached and computedAgo to know how old the figures are.
Any call carrying a root is computed live and never cached, which also makes it the more expensive of the two paths.
Multivalue shares do not sum to the audience
On a multivalue attribute, each top value counts the contacts holding it, and one contact can hold several. The percentages therefore add up to more than 100, and topValuesSemantics says so on the entry. Never narrate them as shares of the audience.
A probe sees more history than a segment evaluates
The event probe reads the event index, which keeps the project's history. A segment's event condition is evaluated against a different, narrower store: the recent events kept on each contact. The practical consequence is one-directional and always the same. A value the probe lists can match fewer people than its count suggests, sometimes none.
What a probe is not
Four neighbouring surfaces, deliberately kept apart:
/audience/counttakes the same filter grammar and returns the exact, live total of matching contacts. It is the only endpoint whose number is a size. A probe never replaces it./audience/aggregationsruns your aggregations over the audience and needs the manually grantedPROJECT_AGGREGATIONSscope. A probe builds its own fixed aggregations and needs onlyPROJECT_AUDIENCE_READ.- Analytics reports on what you sent: delivery, engagement, conversion, cost, over time. Probes report on what you hold: attributes and events, with no time series and no performance data.
GET /specs/attributesandGET /specs/eventsreturn the project's declared schema, described in Contacts and attributes and Events. Probes measure how much of that schema has data behind it.
What's next
- Audience query filter (AQF): the grammar you write once the probe told you which attribute and which value, and the same grammar
/audience/coverageaccepts asroot. - Audience event query filter (EQF): the event-side grammar, and the
root/event/volumesaccepts. - Counting and retrieval: where the number you may actually quote comes from.
- Segments: keeping the filter the probe helped you write.
- Full API Reference: request and response shapes for both endpoints.