# Instasent - product-api (full documentation)
> Autocontained dump of every page under /platform-api/product-api. Paste this into an AI assistant or feed it to an agent as context for product-api integration work. Some behaviour is cross-API: when a page here refers to another API or a shared concept, consult the zone index at https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/llms.txt (and, for product or dashboard questions, the whole-site index at https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt).
Source: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/
Zone index: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/llms.txt
OpenAPI spec: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/openapi/product.openapi.yaml
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/guide
# Product API guide
The Product API is the CDP API — Instasent's Customer Data Platform: the unified audience with its events and segments, plus the organizations, projects, data sources, campaigns, automations and direct messaging around it, all under one token.
**Language:** en
**Audience:** developer
**TLDR:** The Product API is the CDP API — Instasent's Customer Data Platform, and its control plane: manage organizations and projects, merge contacts across data sources into one unified audience, save audience filters as segments, and read campaigns, automations and events, or send direct SMS. Call it with a Product API token and project UID from Project settings; use the A2P Messaging API for raw message throughput, or the Ingest API (a focused subset) to only push customer data.
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/guide/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/guide.md (Markdown)
The Product API is the control plane for everything a brand runs on Instasent. It manages the **organization** and its **projects**, feeds contacts and events into **data sources**, queries the **unified audience** that results from merging those sources, keeps any of those queries as a named **segment**, reads **campaigns** and **automations**, and sends **direct SMS** to specific contacts when your application needs to trigger a message outside a campaign.
If you are looking for raw message throughput — OTPs, receipts, transactional SMS — see the [A2P Messaging API](/a2p-messaging-api/overview). If you only need to push customer data into a project, the [Ingest API](/platform-api/ingest-api/guide) is a focused subset of this one with its own tokens.
> **Note**: You need a **Product API token** and your **project UID** to call these endpoints. Both live under **Project settings** in the [dashboard](https://dashboard.instasent.com).
## What you can do
The Product API gives you, under a single token:
- **Organizations & projects** — read organization info, open projects and manage project-level settings.
- **Data sources** — create and manage API data sources as entry points for customer data.
- **Unified audience** — query, search, count and analyse the contacts that result from merging every data source in the project. The whole surface has its own section: [Audience](/platform-api/product-api/audience/overview).
- **[Events](/platform-api/product-api/audience/events)** — search and analyse audience events to understand customer behaviour.
- **Segments** — list static and dynamic segments, scroll their contacts, and [save an audience filter](/platform-api/product-api/audience/segments) as a named segment the project keeps.
- **Campaigns, automations & flows** — read campaign, automation and flow details, configurations and status, including a flow's versions; [create campaign drafts](/platform-api/product-api/campaigns/overview) for a human to review and send; and [create and edit flow drafts](/platform-api/product-api/flows/overview), from scratch or from a template, for a human to review and publish.
- **Direct messaging** — send direct SMS to specific audience contacts and manage SMS senders.
- **Ingest (built-in)** — push contacts and events into data sources using the same endpoints the [Ingest API](/platform-api/ingest-api/guide) exposes.
## Core entities
Five nouns keep reappearing in every endpoint. Getting them right up front saves pain later.
- **Organization** — the company on Instasent. Owns billing, user accounts, API tokens and a set of projects.
- **Project** — an isolated environment inside an organization. Holds its own data sources, audience, segments, campaigns and automations. Used to split brands, markets or customer segments.
- **Data source** — a stream of contacts and events that feeds into a project. A project can have many. Each has its own contacts and events; merging happens at the audience level.
- **Contact** — a person. Exists in two forms:
- **Datasource contact** — the original record as it arrived in a specific data source.
- **Audience contact** (also called the *unified audience*) — the merged view across every data source in the project.
- **Event** — an immutable record of a customer interaction (purchase, view, opt-in, any custom activity). Events enrich contact profiles and can trigger automations.
## How contacts merge into the unified audience
The unified audience is the product of merging every data source in the project by the **merging attributes** configured on it. `_user_id` is the primary one, a project usually configures extras such as `_email` and `_phone_mobile`, and a match on any of them merges the records into a single audience contact whose event timelines join.
So a contact with `_user_id: "12345"` present in both your CRM and your e-commerce data sources becomes one audience contact, combining both sets of attributes and both histories. When two sources disagree about a value, the project's priority rules pick the winner.
The full model, including what a `datasource` narrowing does and does not mean, is on [Contacts and attributes](/platform-api/product-api/audience/contacts-and-attributes) in the [Audience](/platform-api/product-api/audience/overview) section.
## Discovering what's in a project
The attributes a contact can carry and the event types you can filter on are **project-specific** — they depend on how the project is configured and which datasources feed it. Rather than maintain a static catalogue here, the API exposes three discovery endpoints that always return the live shape of your project:
- `GET /v1/project/{project}/specs/attributes` — every attribute enabled on contacts in this project: its `uid`, `dataType`, whether it is `unique` (used for merging), `custom`, `readonly`, `eventBased`, etc.
- `GET /v1/project/{project}/specs/events` — every event type available in this project, including category, attribution and automation flags.
- `GET /v1/project/{project}/specs/events/{eventType}` — the parameter schema for a specific event type: `parameter` key, `dataType`, `required`, `maxLength`, `multiValue`.
Use these to validate payloads before writing, to build dynamic UIs on top of the audience, or just to discover which event types (`ecommerce_order_create`, `appointment`, custom ones…) and parameters are in play. See the [API Reference](/platform-api/product-api/reference) for response shapes.
Specs answer *what data shape can this project hold*. A second pair of endpoints answers the operational question *is this project ready to operate, and what's blocking it* — also computed live from the project's current configuration:
- `GET /v1/project/{project}/readiness` — the live setup-readiness report for the project: the steps still left to configure, with a percent-complete figure and an operational flag telling you whether the project can send at all.
- `GET /v1/project/{project}/readiness/channel/{channel}` — the same report scoped to one channel (`channel` ∈ `sms` | `rcs`): channel-specific setup steps (add a sender for `sms`, add an agent for `rcs`), a `warnings` array carrying `recommended-registrations` (countries a sender ought to be registered for and is not), and a `reach` object listing the countries you can send to right now on this channel — a union plus a per-sender breakdown (the positive counterpart of `warnings`). `reach` is `null` on the project (home) scope.
- `GET /v1/project/{project}/attention` — the counterpart of the two above: not what is left to configure but **what has broken, or is about to** — an expired card, a connector that stopped syncing, a scheduled campaign the balance will not cover — worst first, with an absolute deadline wherever one exists. It covers the organization behind the project too, so a project-scoped token still sees the organization's unpaid invoice. See [Attention](/platform-api/product-api/project-setup/attention).
- `GET /v1/project/{project}/recommendations` — the third question, once the first two are answered: **what is worth doing next** in a project that already works — a first segment, which of your events counts as a sale, a connector instead of hand-loaded CSVs. Same engine as attention, its own report so neither set of counts can contain the other's items, and ranked by `priority` rather than severity. Nothing is suggested until the project has contacts and a sender. See [Recommendations](/platform-api/product-api/project-setup/recommendations).
Use these to drive an onboarding checklist or a "your project isn't ready yet" banner, to gate sending in your own tooling, or to answer "is everything OK with this account". See the [API Reference](/platform-api/product-api/reference) for the full report shape.
Specs answer what the project *can* hold; a third pair answers what it *actually* holds. `POST /audience/coverage` and `POST /event/volumes` are **probes**: they measure the audience attribute by attribute and event type by event type, so you know an attribute is filled and a value occurs before you build a filter on it. See [Audience probes](/platform-api/product-api/audience/probes). They report shape, never size: for a size, `/audience/count`.
## Tokens and scopes
Access is controlled by **token scopes**. A token is minted for an organization or project and carries only the scopes you grant it — a read-only reporting token looks nothing like the write token behind your CRM sync.
### Datasource management
- `PROJECT_DATASOURCE_READ` — read-only access to data sources.
- `PROJECT_DATASOURCE_WRITE` — create and modify data sources.
### Organization
- `ACCOUNT_READ` — read organization account details, including funds.
### Audience
- `PROJECT_AUDIENCE_READ` — read individual audience contacts, and read segments (list, view, usage).
- `PROJECT_AUDIENCE_WRITE` — write to audience contacts.
- `PROJECT_AUDIENCE_LIST` — list audience contacts (scroll/search). *Requires a specific subscription plan.*
- `PROJECT_AUDIENCE_DATA_BASIC` — access to basic contact data. *Requires a specific subscription plan.*
- `PROJECT_AUDIENCE_DATA_FULL` — access to full contact data (PII). *Must be manually granted by Instasent.*
- `PROJECT_AUDIENCE_DATA_EVENTS` — access to audience events. *Requires a specific subscription plan.*
- `PROJECT_AGGREGATIONS` — audience and event aggregations. *Must be manually granted by Instasent.*
### Segments
- `PROJECT_SEGMENT_WRITE` — [save an audience filter as a segment](/platform-api/product-api/audience/segments). A scope of its own, so a token can be allowed to keep segments without being given write access to the audience. Reading segments back needs `PROJECT_AUDIENCE_READ`, not this one.
### Campaigns, automations & flows
- `PROJECT_CAMPAIGN_READ` — read access to campaigns. Required by [reading campaigns](/platform-api/product-api/campaigns/reading-campaigns), including the project digest.
- `PROJECT_CAMPAIGN_WRITE` — create campaign drafts. Required by [campaign creation](/platform-api/product-api/campaigns/creating-a-draft), and by [estimating and deleting](/platform-api/product-api/campaigns/estimating-and-deleting); it does not allow sending, which stays in the dashboard.
- `PROJECT_AUTOMATION_READ` — read access to automations, and to flows, their versions, their drafts and the [flow templates](/platform-api/product-api/flows/templates).
- `PROJECT_AUTOMATION_WRITE` — create flows and replace or validate their drafts. Required by [creating and editing drafts](/platform-api/product-api/flows/drafts); it does not allow publishing, enabling or archiving a flow, which stay in the dashboard.
### Direct messaging
- `PROJECT_DIRECT_READ` — read direct SMS.
- `PROJECT_DIRECT_WRITE` — create direct SMS. Required to send.
### Data privacy and plan gating
The contact fields a call returns depend on **two** things: the scopes on your token **and** the subscription plan of the organization. Having the scope in the token spec is not enough — the plan has to allow it.
| Level | Required scope | Returned fields |
| ----------- | ----------------------------- | ----------------------------------------------- |
| **Default** | `PROJECT_AUDIENCE_READ` | Full name and user id only. |
| **Basic** | `PROJECT_AUDIENCE_DATA_BASIC` | Phone, email, country, name and boolean fields. |
| **Full** | `PROJECT_AUDIENCE_DATA_FULL` | Full contact data including PII. |
Scopes fall into three availability tiers:
- **Always available**: `PROJECT_AUDIENCE_READ`, `PROJECT_AUDIENCE_WRITE`, `PROJECT_DATASOURCE_READ/WRITE`, `PROJECT_SEGMENT_WRITE`, `PROJECT_CAMPAIGN_READ/WRITE`, `PROJECT_AUTOMATION_READ/WRITE`, `PROJECT_DIRECT_READ/WRITE`, `ACCOUNT_READ`.
- **Gated by subscription plan**: `PROJECT_AUDIENCE_LIST`, `PROJECT_AUDIENCE_DATA_BASIC`, `PROJECT_AUDIENCE_DATA_EVENTS`. These are grantable only on plans that include them — upgrade the plan from the [dashboard](https://dashboard.instasent.com) if you need them.
- **Manually granted by Instasent**: `PROJECT_AUDIENCE_DATA_FULL`, `PROJECT_AGGREGATIONS`. Not generally available, reserved for trusted partners, require approval.
> **Warning**: Design your integration against **Basic** access. If your plan does not include `PROJECT_AUDIENCE_DATA_BASIC`, most contact fields come back redacted to Default level regardless of what your code expects. Upgrade the plan before assuming the data is there.
### Datasource-specific tokens
Every data source can also mint its own token. Those tokens are write-only, scoped to the single data source, and are the recommended path for CRM or e-commerce syncs — see [Ingest API authentication](/platform-api/ingest-api/authentication).
## Multi-project architecture
Projects are fully isolated. Each one keeps its own:
- data sources and contacts,
- unified audience and events,
- segments, campaigns and automations,
- SMS senders and direct messages.
Use that isolation to split brands, markets or product lines without cross-contaminating audiences. A single organization can run many projects side by side.
## What to read next
- [Quickstart](/platform-api/product-api/quickstart) - Find a contact, read their events, send a direct SMS.
- [Authentication](/platform-api/product-api/authentication) - Token types, scopes and rotation.
- [Audience](/platform-api/product-api/audience/overview) - The whole audience surface: contacts, events, filters, probes, counting and segments.
- [Campaigns](/platform-api/product-api/campaigns/overview) - Draft campaigns, read them back, price them and delete them.
- [Full API Reference](/platform-api/product-api/reference) - Every endpoint, every parameter.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/quickstart
# Product API quickstart
Find a contact in the unified audience, read their recent events and send a direct SMS — end to end in under five minutes.
**Language:** en
**Audience:** developer
**TLDR:** Authenticate with a Product API token as a Bearer header against https://api.instasent.com/v1/project/{project}, scoped to PROJECT_AUDIENCE_READ, PROJECT_AUDIENCE_DATA_BASIC/EVENTS and PROJECT_DIRECT_WRITE. Verify it with GET /v1/project/{project} (200 = live token), then resolve a contact via GET /audience/search/phone/{phone} (or /search/email/{email}, /user/{userId}) to get the audience id used for events and direct-send calls.
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/quickstart/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/quickstart.md (Markdown)
This walkthrough exercises the three things every Product API integration eventually does: locate a contact, inspect their timeline, and trigger a direct message. Budget five minutes.
## Before you start
#### 1. Have a project with data
Sign in to the [dashboard](https://dashboard.instasent.com) and pick a project that already has at least one audience contact (either pushed via Ingest or imported via a data source). If your project is empty, walk through the [Ingest Quickstart](/platform-api/ingest-api/quickstart) first.
#### 2. Create a Product API token
Open **Project settings** → **API tokens** and create a token with at least these scopes:
- `PROJECT_AUDIENCE_READ`
- `PROJECT_AUDIENCE_DATA_BASIC` (to see phone and email on responses)
- `PROJECT_AUDIENCE_DATA_EVENTS` (to read events)
- `PROJECT_DIRECT_WRITE` (to send direct SMS)
#### 3. Export credentials
```bash
export INSTASENT_PROJECT="proj_xxx"
export INSTASENT_TOKEN="eyJhbGciOi..."
export BASE="https://api.instasent.com/v1/project/$INSTASENT_PROJECT"
```
## 1. Verify the token
`GET /v1/project/{project}` returns the project's metadata and is the cheapest probe for credentials.
```bash
curl "$BASE" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
A `200` confirms the token is live and the project UID resolves. A `401` means the token is wrong; a `404` means the project UID is wrong or not visible to the token.
## 2. Find an audience contact
The audience exposes three search helpers for the common identifier types — user id, phone and email. They return the unified audience contact so you can then read events or send messages.
```bash
curl "$BASE/audience/search/phone/%2B34600000000" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Response (trimmed):
```json
{
"entity": {
"id": "uQTuHNBKLdwTxzGldW5pocUNqzyz-066",
"_user_id": "USER-123",
"_first_name": "Ada",
"_email": "ada@example.com",
"_phone_mobile": "+34600000000"
}
}
```
Copy the returned `id` — that is the **audience contact id** you need for the next two calls.
```bash
export AUDIENCE_ID="uQTuHNBKLdwTxzGldW5pocUNqzyz-066"
```
> **Tip**: Phone numbers in the path must be URL-encoded (`+` → `%2B`). If you store user ids instead, use `/audience/user/{userId}`; for email use `/audience/search/email/{userEmail}`.
## 3. Read the contact's recent events
```bash
curl "$BASE/audience/$AUDIENCE_ID/events" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
You get the last events for that contact — purchases, message deliveries, clicks, custom events. Use this to confirm your Ingest pipeline is landing and to drive application logic that depends on the customer timeline.
## 4. Send a direct SMS
The direct SMS endpoint takes the sender and audience id in the path and the text in the body. Use `"default"` as the sender id to fall back to the project's default sender.
```bash
curl -X POST "$BASE/channel/sms/sms/direct/default/$AUDIENCE_ID" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"text": "Hi Ada — your order is on its way. Track it at {{short:https://track.example.com/O-987}}"
}'
```
A `201 Created` returns the SMS entity with its `id`, `status` (`enqueued` initially), `encoding`, `messagesCount`, `pricePerSms` and the `audienceId` the message was dispatched to. Status transitions are pushed to your DLR webhook the same way transactional traffic is — see [Transactional DLRs](/a2p-messaging-api/http/dlrs).
> **Warning**: The direct SMS endpoint supports `{{short:URL}}` for automatic link-shortening and `{{unsubscribe}}` for an opt-out link. It is designed for individual triggered messages, not bulk campaigns — those belong in a campaign or automation.
## 5. (Optional) send by phone instead of audience id
If the contact does not yet exist and your project has **outbound auto-creation** enabled, you can send straight to a phone number and let the API create the audience contact for you:
```bash
curl -X POST "$BASE/channel/sms/sms/direct/default/%2B34600000000" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "text": "Welcome to Example Co." }'
```
The response's `metadata.autoCreated` is `true` when a new audience contact was created for the message. If auto-creation is disabled and the phone number is not found, the call returns `404`.
## 6. Check what's left to configure
Two read-only endpoints answer "is this project ready to send?" — handy for onboarding screens or a pre-flight check before you start sending. Both are recomputed on every call: there is no cached "done" flag, so a completed step re-opens if its condition regresses. The separate question — what has actually broken — is [attention](/platform-api/product-api/project-setup/attention).
`GET /v1/project/{project}/readiness` returns the project-wide setup report: an `operational` flag, a `percent` complete, and a list of `steps` (import contacts, set up channels, complete the legal profile, cover the balance). Each step carries a stable `key` — the API ships no copy — plus `completed`, `isBlocker`, `disabled`, `skipped` and `skippable`.
```bash
curl "$BASE/readiness" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Response (trimmed):
```json
{
"entity": {
"scope": "project",
"channel": null,
"percent": 75,
"operational": false,
"steps": [
{ "key": "import", "completed": true, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true },
{ "key": "channels", "completed": true, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true },
{ "key": "legal-profile", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true },
{ "key": "funds", "completed": false, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true }
],
"warnings": [],
"reach": null
}
}
```
`GET /v1/project/{project}/readiness/channel/{channel}` narrows the report to a single channel (`sms` or `rcs`; any other value returns `404`). On top of channel-specific steps (`add-sender` for SMS, `add-agent` for RCS) it surfaces `warnings` — today just `recommended-registrations`, the countries a sender ought to be registered for and is not — and `reach`, the countries the channel can send to right now.
```bash
curl "$BASE/readiness/channel/sms" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Response (trimmed):
```json
{
"entity": {
"scope": "channel",
"channel": "sms",
"steps": [
{ "key": "add-sender", "completed": true, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true }
],
"warnings": [],
"reach": {
"reachableCountries": ["ES", "FR", "PT"],
"senders": { "ACME": ["ES", "PT"], "INFO": ["FR"] },
"senderCount": 2,
"defaultSender": "ACME"
}
}
}
```
> **Tip**: On a channel, `operational` also requires a non-empty `reach`: holding a sender is not the same as being able to deliver with it. The full field-by-field reference is in [Project readiness](/platform-api/product-api/project-setup/readiness) and [Channel readiness](/platform-api/product-api/project-setup/channel-readiness).
## 7. Count your reachable audience
Readiness tells you the channel is set up; a count tells you whether there is anyone to send to. `POST /v1/project/{project}/audience/count` returns only a total, and `filterCompliance` makes that total mean *contacts an SMS campaign under this consent policy would reach* instead of *contacts in the project*.
```bash
curl -X POST "$BASE/audience/count" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "filterCompliance": { "sms": "no-opt-out" } }'
```
Response:
```json
{
"metadata": {
"totalHits": 452
}
}
```
Swap the policy for `basic` or `opt-in` to compare what each one reaches, add a `root` to count a segment or a set of conditions, and read the project's own policy from `generalConfig.channelSms.defaultCompliancePolicy` in step 1's response. The full key is in [Counting who can receive](/platform-api/product-api/audience/query-filter#counting-who-can-receive-filtercompliance).
## What to read next
- [Guide](/platform-api/product-api/guide) - Mental model, entities and how the unified audience is built.
- [Audience query filter](/platform-api/product-api/audience/query-filter) - Search and segment the unified audience.
- [Authentication](/platform-api/product-api/authentication) - Token types, scopes and rotation.
- [Full API Reference](/platform-api/product-api/reference) - Every endpoint, every parameter.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/authentication
# API tokens
Authenticate the Product API with an organization- or project-scoped bearer token — the default method for your own server-side integrations.
**Language:** en
**Audience:** developer
**Search keywords:** api key, api token
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/authentication/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/authentication.md (Markdown)
The Product API has two ways to authenticate: **API tokens** (this page) for your own server-side integrations, and **[Connected apps (OAuth)](/platform-api/product-api/connected-apps-and-oauth)** when a customer authorizes a third-party application against their project. This page covers tokens.
Every request to the Product API carries a bearer token in the `Authorization` header. Tokens are issued in the dashboard, scoped to an organization or a project, and grant only the permissions you explicitly check off when you create them.
## Two kinds of token
- **Product API token** — the full-feature token. Can read and write any surface of the Product API the scopes allow: audience, events, segments, campaigns, automations, direct SMS and — because the Ingest API is a subset of Product — contacts and events in any data source of the project.
- **Datasource token** — a narrower token minted for a single data source. Write-only against that data source. The right choice for CRM or e-commerce syncs that should not see anything else. See [Ingest authentication](/platform-api/ingest-api/authentication).
Use the widest token only where the workload actually needs it. A reporting dashboard does not need `PROJECT_AUDIENCE_WRITE`; a CRM sync does not need any read scopes.
## Getting a token
#### 1. Open Project settings
Sign in to the [dashboard](https://dashboard.instasent.com), pick the project that will own the token, and open **Project settings** → **API tokens**.
#### 2. Pick the scopes
Check only the scopes the integration needs — see the [scope list in the Guide](/platform-api/product-api/guide#tokens-and-scopes). Tokens are immutable once created; if you need a different scope later, mint a new token and rotate.
#### 3. Copy the token and the project UID
Both are needed for every call. The token is shown once; the project UID appears on the same page and does not change.
> **Warning**: Treat tokens as production secrets. Keep them in an environment variable or a secrets manager; never commit them to the repo or embed them in client-side code. `PROJECT_AUDIENCE_DATA_FULL` tokens in particular carry PII access and should live in your most restricted vault.
## Sending the token
Preferred in every environment: the `Authorization` header. The token never appears in URLs, logs or browser history.
```bash
curl "https://api.instasent.com/v1/project/$PROJECT" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
A missing, malformed or revoked token returns `401 Unauthorized`. A token that is valid but lacks the scope for the endpoint returns `403 Forbidden`.
## Scopes recap
Three scopes cover most day-to-day integrations:
- `PROJECT_AUDIENCE_READ` + `PROJECT_AUDIENCE_LIST` + `PROJECT_AUDIENCE_DATA_BASIC` — segmentation and reporting dashboards.
- `PROJECT_DATASOURCE_WRITE` + `PROJECT_AUDIENCE_WRITE` — CRM / e-commerce syncs and automation backends.
- `PROJECT_DIRECT_WRITE` — triggering direct SMS from your application.
See the [Guide](/platform-api/product-api/guide#tokens-and-scopes) for the full list, privacy levels and the scopes that require manual grant by Instasent.
## Rotating a token
Tokens do not expire. Rotate them whenever a teammate leaves, whenever a secret might have been exposed, and at least once a year as a hygiene measure.
#### 1. Issue the replacement
Create a new token with the same scopes **before** revoking the old one. This keeps traffic flowing while you redeploy.
#### 2. Roll it out
Update your secrets store and redeploy every worker that calls the Product API.
#### 3. Revoke the old token
Once the replacement is live everywhere, delete the old token in the dashboard. Any request still using it will fail with `401`.
## What's next
- **[Rate limits](/platform-api/product-api/rate-limits)** — per-plan ceilings and the `X-RateLimit-*` headers.
- **[Errors](/platform-api/product-api/errors)** — status codes and retry guidance.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/connected-apps-and-oauth
# Connected apps (OAuth)
For software other Instasent customers will install: let each of them authorize your application against their own project with OAuth 2.1, and never handle their credentials. Full Product API access, scoped to what they grant.
**Language:** en
**Audience:** developer
**Search keywords:** oauth, connected app, connected apps, authorization, oauth 2.1, dcr, dynamic client registration, scopes, permissions, verified app, third party app, authenticate as app, api key, api token, isv, build an integration, install my app, multi tenant
**Related pages:** /platform-api/product-api/authentication, /platform/en/developers-and-apps/connect-your-ai
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/connected-apps-and-oauth/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/connected-apps-and-oauth.md (Markdown)
## Who this is for
You are building software that **other Instasent customers will install** — an
integration, a product, an app in someone's stack. Each of them authorizes it
against their own project, you never see their credentials, and any of them can
cut you off from their dashboard without involving you.
If that isn't you, you probably want the other door:
- [Integrating your own account](/platform-api/product-api/authentication) - Your backend calling Instasent for your own organization, with no other customers involved. Create an API token and carry it — none of the OAuth machinery below applies.
- [Connecting an assistant you use](/platform/en/developers-and-apps/connect-your-ai) - Not building anything, just want your own AI working with your project? That is the MCP server, and it needs no code at all.
> **Note**: For the dashboard-side view — how a customer approves a connection and revokes
> it — see [Developers & Apps](/platform/en/developers-and-apps) in the Platform
> zone. The full OAuth surface (`/oauth/register`, `/oauth/token` and the
> discovery documents) is in the
> [API reference](/platform-api/product-api/reference).
A **connected app** is the second way to authenticate the Product API (the other is [API tokens](/platform-api/product-api/authentication)). Instead of a customer pasting a long-lived token into your software, **they authorize your application against their project with OAuth 2.1** — and can revoke it at any time from the dashboard.
Once authorized, a connected app calls the **full Product API** with the scopes the customer granted: audience, events, segments, campaigns, automations, direct SMS and contact ingestion — the same surface a token of equivalent scope reaches. This is the path for building an integration or product on top of Instasent that many customers install.
> **Warning**: A connected app (OAuth) is **not** the same as the
> [MCP server](/platform/en/developers-and-apps/connect-your-ai). A connected app
> authenticates against the **full** Product API; MCP is a separate,
> agent-oriented surface with a curated tool set that reads and drafts but
> never sends. They both use OAuth, but they grant very different access.
## Registering your application
You don't pre-arrange anything with us. **Dynamic Client Registration**
(RFC 7591) creates the client itself: `POST /oauth/register` with your
application's name and redirect URIs returns the `client_id` you then drive the
authorization code flow with. There is no client secret — every client
registered this way is a **public client** and proves itself with PKCE alone.
> **Warning**: **Registration is rate-limited, and it is meant to be a one-off.** Register
> once and reuse the `client_id` — don't register on every run, from every
> developer's machine, or as part of your test suite. If a registration is
> refused unexpectedly, that is the most likely reason: wait a while and retry
> before assuming your request is malformed.
A client registered this way is **unverified**. It works, and the consent screen
presents it as an application Instasent hasn't reviewed — which the user has to
acknowledge explicitly before continuing. Note that a project can also be set to
accept only verified applications, in which case an unverified client cannot be
authorized on it at all.
Rejections come back in the RFC 7591 shape — `{"error", "error_description"}`,
not the platform's generic error envelope — with `invalid_client_metadata`,
`invalid_redirect_uri` or `client_name_not_allowed`.
## The rest of the surface
The full contract — every parameter, every error code, the discovery documents
and the scope list — is in the [API reference](/platform-api/product-api/reference):
- `GET /oauth/authorize` — start the flow in the browser; redirects to consent.
- `POST /oauth/register` — dynamic client registration.
- `POST /oauth/token` — code exchange and refresh.
- `/.well-known/oauth-protected-resource`, `/.well-known/oauth-authorization-server`
and `/.well-known/openid-configuration` — discovery. Read the endpoints from
these rather than hard-coding them.
> **Note**: **`/oauth/authorize` lives on this host, but nothing is decided there.** Open
> it in the user's browser with your PKCE parameters and it answers `302`,
> forwarding them to the dashboard — which is what authenticates the user and
> renders the consent screen. It exists here so the whole authorization server
> is advertised on **one origin**: `issuer`, `authorization_endpoint`,
> `token_endpoint` and `registration_endpoint` all share the API host, which is
> what strict clients require.
## Related
- [API tokens](/platform-api/product-api/authentication) — the other authentication method, for your own server-side integrations.
- [AI agents](/platform/en/developers-and-apps/connect-your-ai) — Platform overview for dashboard users.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/readiness
# Project readiness
A single GET returns whether a project can operate, how far its setup has come and which steps are still open — recomputed on every call, with stable step keys and no cached completion flag.
**Language:** en
**Audience:** developer
**TLDR:** GET /v1/project/{project}/readiness returns the project-level setup report: percent, operational, and an ordered steps[] where each step carries a stable key plus completed, isBlocker, disabled, skipped and skippable. operational is true when no blocker step is incomplete. The report is recomputed on every call — a completed step re-opens if its condition regresses — and the home scope always returns warnings: [] and reach: null. What has broken rather than what is missing is a separate endpoint, GET /project/{project}/attention.
**Search keywords:** readiness, setup, onboarding, checklist, is the project ready, can I send, operational, blocker, percent complete, setup progress, what's left to configure, steps, step key, legal profile, legal-profile api_sms, funds, balance, import contacts, project type, api_sms, a2p messaging project, what is api_sms, standard project, pre-flight check, skipped step
**Related pages:** /platform-api/product-api/project-setup/channel-readiness, /platform-api/product-api/project-setup/attention, /platform-api/product-api/quickstart
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/readiness/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/readiness.md (Markdown)
Before a project can send anything it has to hold a few things: contacts, a sender and available balance. The readiness endpoints answer that in one call, so an onboarding screen, an installer or a pre-flight check does not have to ask five different endpoints and infer the answer.
There are two scopes. This page covers the **project (home)** report — the setup of the project as a whole. The [channel report](/platform-api/product-api/project-setup/channel-readiness) narrows the same shape to SMS or RCS and adds the countries that channel can reach.
> **Note**: Readiness answers **what is left to configure**. Its counterpart, [attention](/platform-api/product-api/project-setup/attention), answers **what has broken, or is about to**. One fact has one owner and they never report the same thing twice: absence is always readiness, so a project that just finished signup has a full checklist and no alerts at all.
[`GET /v1/project/{project}/readiness` - The project-level setup report: what is done, what is left, and whether the project can operate.](/platform-api/product-api/reference)
Any Product API token with access to the project can read it; no extra scope is required.
## It is a live report, not a saved state
There is no "onboarding finished" flag behind this. Every call recomputes the report from the project's current data, which has one consequence worth designing for: **a completed step re-opens when its condition regresses.** A project that spends its balance sees `funds` go back to `completed: false`, and `operational` follows it. Treat the response as the current answer, never as a milestone you can persist and stop asking about.
The report carries `computedAt` for exactly that reason. It is always "now" at the moment of the response — but a client that fetches once and renders for the next ten minutes holds an answer that is ten minutes old, and needs to know it.
## The report
```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT/readiness" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
```json
{
"entity": {
"scope": "project",
"projectType": "standard",
"channel": null,
"percent": 29,
"operational": false,
"steps": [
{ "key": "import", "completed": true, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true },
{ "key": "channels", "completed": true, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true },
{ "key": "legal-profile", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true },
{ "key": "funds", "completed": false, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true },
{ "key": "campaign", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true },
{ "key": "automation", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true },
{ "key": "brand", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true }
],
"warnings": [],
"reach": null,
"computedAt": "2026-09-04T16:20:31Z"
}
}
```
- `scope` — `string`
`project` here, `channel` on the channel report. Both scopes return the same object, so a client can render either with one renderer.
- `projectType` — `string`
`standard` or `api_sms`. The wire value is `api_sms`, but nobody calls it that outside the API: to the customer it is an **A2P Messaging project**, the one that sends through the [A2P Messaging API](/a2p-messaging-api/overview) instead of from the panel. Say it that way in any UI you build on this field — `api_sms` means nothing to the person reading your screen. It decides which steps the project gets — see [the step catalogue](#the-step-catalogue).
- `channel` — `string | null`
Always `null` on this scope.
- `percent` — `integer`
Completed steps over total steps, `0`–`100`. `100` when there are no steps at all.
- `operational` — `boolean`
`true` when no blocker step is left incomplete — the project can work. Non-blocker steps do not hold it back, so a project can be `operational` at 60%.
**It describes the account's state; it does not grant or withhold permission to send.** A project can report `operational: false` and keep delivering messages exactly as before. Treat it as the answer to "is this account fully set up", not as a gate in front of your traffic.
- `steps` — `object[]`
The setup milestones, in display order. Order is significant: render them as they arrive.
- `warnings` — `object[]`
Always `[]` on this scope. The one warning that exists — `recommended-registrations` — is per-channel and lives on the [channel report](/platform-api/product-api/project-setup/channel-readiness#warnings). What has actually broken is a different endpoint: [attention](/platform-api/product-api/project-setup/attention).
- `reach` — `object | null`
Always `null` on this scope. Send reach is a property of a channel, not of a project.
- `computedAt` — `string`
When the backend built this report, UTC ISO-8601.
## A step
- `key` — `string`
Stable identifier, kebab-case. **The API ships no copy**: the key is the contract and the label is yours to write and translate.
- `completed` — `boolean`
Whether the condition behind the step holds right now.
- `isBlocker` — `boolean`
When `true`, the project cannot operate until the step is completed. The incomplete blockers are exactly what `operational: false` is telling you about.
- `disabled` — `boolean`
When `true`, a prerequisite is missing and the user cannot act on the step yet — a campaign cannot be created before there are contacts. Render it, but don't offer the action.
- `skipped` — `boolean`
Whether the user hid this step from the dashboard checklist. **A preference, not a fact**: the backend reports it and honours none of it, so `completed`, `isBlocker`, `disabled`, `percent` and `operational` are all computed as if it were `false`. Each client decides what to do with it — hide the row, grey it out, or ignore the field entirely.
Unlike a dismissed [attention item](/platform-api/product-api/project-setup/attention#the-dismiss-field), a skip is stored on the platform and shared by everyone working on the project.
- `skippable` — `boolean`
Whether a user may hide this step at all. `true` for every step today; it exists so that the day one becomes mandatory, the decision is declared once here and every surface obeys it.
It outranks a stored skip: on a step that is not `skippable`, `skipped` is always `false` even if a preference was saved before it stopped being skippable. Nothing ever claims "you hid this" about something that can no longer be hidden, so there is no stale preference for you to clean up.
## The step catalogue
The step set depends on `projectType`, and `funds` only appears where the account needs available balance. Both lists are ordered as returned.
**`standard`** — a project operated from the dashboard:
| Key | Blocker | Completed when |
| --------------- | ----------------------------- | --------------------------------------------------------------------------------------------------- |
| `import` | yes | The project has at least one data source. |
| `channels` | only while there is no sender | There is an active sender **and** no channel the customer asked for at signup is still without one. |
| `legal-profile` | no | The organization has filled in the legal profile that sender registrations are built from. |
| `funds` | yes | The available balance is above the minimum. |
| `campaign` | no | At least one campaign exists. Disabled until `import` is done. |
| `automation` | no | At least one automation exists. Disabled until `import` is done. |
| `brand` | no | The project has a brand with its basic details filled in. |
**`api_sms`** — the **A2P Messaging project**: used as a sending API rather than from the panel:
| Key | Blocker | Completed when |
| --------------- | ----------------------------- | ---------------------------------------------------------------------------------------- |
| `token` | yes | The organization has an API SMS token. |
| `channels` | only while there is no sender | Same condition as above. |
| `legal-profile` | no | Same condition as above. Added to this list on 2026-09-07. |
| `funds` | yes | The available balance is above the minimum. |
| `send-message` | no | A message went out on SMS or RCS in the last 30 days. Disabled while there is no sender. |
Three of these deserve a note:
- **`legal-profile` appears on both lists and blocks neither.** It stopped being a blocker on 2026-09-07, on the same reasoning that governs `operational` itself: a missing legal profile does not stop a project sending, so it must not make one report as inoperative. A project that has been sending for months keeps reporting `operational: true` whether or not the profile is filled in.
It is still on both lists, and worth completing, because **a sender registration cannot be filed with a carrier or a regulator without it** — so the day the customer needs an alias in a country that requires registration, this is what they will be missing. That is a gap in coverage, not a project that cannot work, which is exactly the difference between an incomplete step and a blocker.
- **`channels` is stricter to complete than to unblock.** One active sender means the project *can* send, so the step stops being a blocker there. But if the customer asked for RCS at signup and only SMS is up, the step stays incomplete — the percentage reflects the work left without declaring a working project inoperative. Read `isBlocker` from the response rather than assuming it.
- **`send-message` is a rolling window on purpose.** It re-opens after 30 days without traffic, so the checklist doubles as a drift cue on an integration that has gone quiet.
## Using it
Two integrations cover most cases:
#### 1. Drive a setup checklist
Render `steps` in order, map each `key` to your own copy, and use `percent` for the progress bar. Poll it after the user acts — the report is the only thing that decides a step is done.
#### 2. Pre-flight a send
Read `operational` before you start pushing traffic. When it is `false`, the incomplete `isBlocker` steps are the reason, and they name what to fix. Surface them rather than refusing to send on them: nothing here blocks delivery, so a client that gates its own traffic on this field will stop sending on a project that is sending fine.
Readiness answers whether the project *can* send. Whether there is anyone to send *to* is a different question — [count your audience](/platform-api/product-api/audience/counting-and-retrieval#counting) for that.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/channel-readiness
# Channel readiness
The same readiness report narrowed to SMS or RCS: the channel's own setup steps, the countries a sender still ought to be registered for, and the countries it can actually send to right now.
**Language:** en
**Audience:** developer
**TLDR:** GET /v1/project/{project}/readiness/channel/{sms|rcs} returns steps (add-sender / add-agent, register-country, first send), one warning (recommended-registrations — countries a sender should be registered for and is not, only once the channel has an active sender) and reach: the countries you can send to now, with a per-sender breakdown, senderCount and defaultSender. On this scope operational also requires a non-empty reach: clearing every blocker is not the same as being able to deliver. What a carrier or regulator DID to a registration — pending-review, rejected-registration — is not here: it moved to GET /project/{project}/attention on 2026-09-05. A country's own paperwork is a separate list on separate endpoints (GET /project/{project}/channel/registration-progress, then the per-country checklist), never mixed into these steps.
**Search keywords:** channel readiness, sms readiness, rcs readiness, which countries can I send to, reachable countries, coverage, sender registration, register a country, recommended registrations, warnings, add sender, add agent, default sender, senderCount, regulation, can I send to Spain, no coverage, operational, where did pending-review go, where did rejected-registration go, what does a country require, registration checklist, registration progress, registration paperwork, which countries have steps pending
**Related pages:** /platform-api/product-api/project-setup/readiness, /platform-api/product-api/project-setup/attention, /platform/en/channels/countries
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/channel-readiness/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/channel-readiness.md (Markdown)
The [project report](/platform-api/product-api/project-setup/readiness) answers "is this project set up". This one answers the two questions you actually ask before sending on a channel: **what is still missing here**, and **who can I reach right now**.
It returns the same object, with `scope: "channel"`, and fills in the two fields the project scope always leaves empty: `warnings` and `reach`.
What it does **not** answer is what a carrier or a regulator did to a registration you already submitted. That moved out on 2026-09-05 — see [What moved to the attention endpoint](#what-moved-to-the-attention-endpoint).
[`GET /v1/project/{project}/readiness/channel/{channel}` - Setup steps, the recommended-registrations warning and real send reach for one channel.](/platform-api/product-api/reference)
`channel` is `sms` or `rcs`. Any other value returns `404` — it is a route constraint, not a validation error, so don't expect a field-level message back.
```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT/readiness/channel/sms" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
```json
{
"entity": {
"scope": "channel",
"projectType": "standard",
"channel": "sms",
"percent": 50,
"operational": true,
"steps": [
{ "key": "add-sender", "completed": true, "isBlocker": true, "disabled": false, "skipped": false, "skippable": true },
{ "key": "send-first-sms-campaign", "completed": false, "isBlocker": false, "disabled": false, "skipped": false, "skippable": true }
],
"warnings": [
{
"key": "recommended-registrations",
"severity": "error",
"count": 2,
"rows": [
{ "channel": "sms", "senderId": "5f3a9b2c4d1e0f00112233ab", "senderName": "MYBRAND", "country": "ES", "source": "regulation", "urgency": "urgent", "audienceCount": null, "audienceShare": null, "reason": null },
{ "channel": "sms", "senderId": "5f3a9b2c4d1e0f00112233ab", "senderName": "MYBRAND", "country": "FR", "source": "audience", "urgency": null, "audienceCount": 412, "audienceShare": 0.0824, "reason": null }
]
}
],
"reach": {
"reachableCountries": ["PT"],
"senders": { "MYBRAND": ["PT"] },
"senderCount": 1,
"defaultSender": "MYBRAND"
},
"computedAt": "2026-09-04T16:20:31Z"
}
}
```
## Steps
Same [step object](/platform-api/product-api/project-setup/readiness#a-step) as the project report — stable `key`, no copy — with a set that depends on the channel and the project type:
| `projectType` | `channel` | Steps, in order |
| ------------- | --------- | ---------------------------------------------------------- |
| `standard` | `sms` | `add-sender`, `send-first-sms-campaign` |
| `standard` | `rcs` | `add-agent`, `register-country`, `send-first-rcs-campaign` |
| `api_sms` | `sms` | `add-sender`, `send-sms-recent` |
| `api_sms` | `rcs` | `add-agent`, `register-countries`, `send-rcs-recent` |
`api_sms` is the wire value for what the customer knows as an **A2P Messaging project** — the one that sends through the [A2P Messaging API](/a2p-messaging-api/overview). Never show the raw value in a screen someone reads: it is the one field on this endpoint whose value has a customer-facing name that doesn't look like it.
The first step of each list is the blocker; the rest are `disabled` until it is done. `register-country` / `register-countries` completes as soon as one RCS registration has been submitted — submitted, not approved, because approval is the carrier's and the regulator's timeline, not a step the customer can complete. The `*-recent` steps look at the last 30 days, so they re-open on a quiet integration.
**SMS has no per-country step.** Registration exists on SMS too, but it is not something the customer starts by hand, so it never becomes a checklist item. What it does produce is the warning below.
## Warnings
Steps are what the customer can complete. **A warning is what the customer should act on but cannot tick off** — a country a sender ought to be registered for and is not. It appears only once the channel has an active sender: before that there is nothing to recommend registering.
One key:
| `key` | `severity` | What it means |
| --------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `recommended-registrations` | `error` | Countries a sender **should** be registered for and is not. `count` is the total and may exceed `rows`, which is capped at 8. |
> **Warning**: **Not to be confused with `/project/{project}/recommendations`.** Three things in this API share the word and only this one is about coverage: `recommended-registrations` is a country a sender ought to be registered for and is not. The [recommendations endpoint](/platform-api/product-api/project-setup/recommendations) carries items of `type: "recommendation"` — next steps worth taking in a project that already works — and never mentions registrations. The MCP server's channel-readiness tool exposes this same warning under a field called `recommendations`, which is this one, not that endpoint.
`warnings` stays an array discriminated by `key` even with a single member, so narrowing on the key is still the right thing to write: the next warning to arrive inherits that shape instead of adding nullable fields to this one.
Every warning carries `key`, `severity`, `count` and `rows`. **`rows` is discriminated by the parent's `key`, never by which fields came back null.**
Rows of `recommended-registrations` describe a registration that does **not** exist, so they carry no status. Instead they say why the country is recommended:
- `channel` — `string`
`sms` or `rcs`.
- `senderId` — `string`
The sender the recommendation is for.
- `senderName` — `string`
Its alias — the SMS `from`, or the RCS agent name.
- `country` — `string`
ISO 3166-1 alpha-2.
- `source` — `string`
`regulation` — a regulator requires it; `audience` — you hold contacts there; `org` — it is the organization's own country.
- `urgency` — `string | null`
`urgent`, `upcoming` or `far`. How close the regulator's deadline is; `null` unless `source` is `regulation`.
- `audienceCount` — `integer | null`
Reachable contacts in that country; `null` unless `source` is `audience`.
- `audienceShare` — `number | null`
That country's `audienceCount` over the project's contacts with a mobile phone, between 0 and 1 and unrounded: round it yourself for display. It is the same base the audience threshold is measured against, since a country is recommended for its audience only when it holds at least 1% of those contacts and at least 25 of them. `null` unless `source` is `audience`.
- `reason` — `string | null`
`sender-substituted` when that sender can already reach the country, but only with a different sender shown to the recipient. The recommendation stays, and stays urgent: that delivery is a temporary last resort, and registering is what fixes it. `null` otherwise.
A country already covered by a non-archived registration on that sender never appears here, whatever its status.
> **Note**: `audience` rows are left out only when the audience's country distribution is unavailable: the project has no audience, or the distribution could not be obtained at that moment. The distribution is refreshed at most every six hours, so contacts from a recent import can take up to that long to show here. `regulation` and `org` rows are always there when they apply, so a missing audience contribution is silent, never an error.
### What moved to the attention endpoint
Until 2026-09-05 this array carried two more keys, and if you integrated before that date they are what you are missing:
| Old warning key | Where it lives now |
| ----------------------- | ------------------------------------------------------------------------------------------------- |
| `pending-review` | [`GET /project/{project}/attention`](/platform-api/product-api/project-setup/attention), same key |
| `rejected-registration` | [`GET /project/{project}/attention`](/platform-api/product-api/project-setup/attention), same key |
The key names survived the move, so the switch you already wrote still matches; what changed is the endpoint it reads from and the shape around it — an attention item is **one occurrence** with `entities` and `data`, not a warning with `rows`.
The reason is the boundary the two engines are built on: **readiness answers what is left to configure, attention answers what has broken.** A registration a carrier is still reviewing, or one a regulator turned down, is something that happened to a registration the customer already submitted — an event, not a configuration gap. `recommended-registrations` stayed because it is the opposite: a registration that does not exist yet.
> **Warning**: One consequence worth designing for: a rejected registration appears in **both** reports at once, on purpose. Attention reports the rejection as news, with its cause, for 30 days; readiness lists the country under `recommended-registrations` because a rejected registration is not coverage. They are not duplicates — they say different things and both are true. Render them where each belongs rather than de-duplicating them.
## Reach
`reach` is the positive counterpart of the warnings: **the countries this channel can send to right now.**
- `reachableCountries` — `string[]`
The union across the channel's active senders, ISO 3166-1 alpha-2, deduplicated and sorted.
- `senders` — `object`
Per-sender breakdown, keyed by alias. A sender that reaches no country is omitted, so this map can be shorter than the sender list — and it may arrive as `[]` rather than `{}` when it is empty. Normalise both to an empty map before you index it.
- `senderCount` — `integer`
How many **active** senders the channel has, including those that reach nothing. This is not the size of `senders`, and the gap is the interesting part: an RCS agent with no accepted registration counts here and is absent there.
- `defaultSender` — `string | null`
The project's default sender, when it is one of the active ones. It matters because a send that names no sender uses it — so the union above can promise more than a single send would actually reach. Always `null` on `api_sms` (A2P Messaging) projects, where nothing picks a default.
Reach is registration-aware, but not every pending register costs you a country. A country drops out when a registration is required and missing, when the register is waiting on the **regulator** axis (not yet classified, or filed and awaiting a decision), or when it is rejected or revoked. A register still being processed by the **operators** does not drop it: on SMS the country stays reachable while that runs. Reach is not a delivery gate: a country that drops out of reach can still receive messages — they go out, they are billed, and delivery is best-effort, so the operator may replace the sender or filter the message. What reach tells you is where the sender is cleared to arrive as itself. The two channels start from opposite defaults, which is why the same project can look very different on each:
- **SMS** allows by default over the operator catalogue — the same set as the sender's `acceptedCountries` — and subtracts what regulation blocks.
- **RCS** is registration-driven, so it can be **stricter** than the agent's `acceptedCountries`, which reflects carrier support only.
## `operational` on a channel
On this scope `operational` asks for more than the project one does: **no incomplete blocker step *and* at least one reachable country.**
That second clause is not redundant. Holding a sender is not the same as being able to deliver with it: an RCS agent with no accepted registration completes `add-agent` and still reaches nobody. `operational` claims capability, so it answers to reach.
> **Tip**: Before a send, `operational` plus `reach.reachableCountries` is the whole pre-flight: the first says the channel works, the second says where. If you also need to know whether there is anyone there, [count the audience](/platform-api/product-api/audience/counting-and-retrieval#counting).
## What a country requires before you get there
`reach` tells you where this project can arrive **today**. It doesn't tell you what a
country asks for — the paperwork, who files it, how long it takes, whether it costs
anything — and that is exactly what you need when a country is missing from the list and
you want it in.
Those rules are documented once, for the dashboard and for the API alike:
- [Countries](/platform/en/channels/countries) - Every destination, with what it asks for on each channel.
- [Coverage & delivery](/platform/en/channels/sms/coverage-and-delivery) - What happens on SMS while a registration is still being filed.
### Asking the API instead
Those pages say what a country asks of anyone. Two endpoints say what it still asks of **one sender in this project**:
[`GET /v1/project/{project}/channel/registration-progress` - One row per sender and country that has recorded paperwork, with how much of it the customer can act on right now.](/platform-api/product-api/reference)
[`GET /v1/project/{project}/channel/{channel}/sender/{id}/register/{country}/checklist` - The steps one country still asks of this sender, and which of them are startable today.](/platform-api/product-api/reference)
Start with the progress endpoint, always. Most destinations record no paperwork at all, so it is the only thing that tells you which sender-and-country pairs are worth a checklist call; a country that publishes nothing is **left out of the rows** rather than returned as zeroes, because "nothing to do here" and "nothing is written up for this country" are different answers.
> **Warning**: **Those steps are not the `steps` on this report.** The ones above are product setup — add a sender, send something — and the customer clears them in the dashboard. The ones on the checklist are a country's **paperwork**: sign a letter, allowlist a verifier, email an operator. A project can be fully `operational` here and still owe one country a signed letter, so never merge the two into a single list.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/attention
# Attention
One GET returns everything currently wrong with a project and the organization behind it — an expired card, a connector that stopped syncing, a scheduled campaign the balance will not cover — worst first, with an absolute deadline wherever one exists.
**Language:** en
**Audience:** developer
**TLDR:** GET /v1/project/{project}/attention returns items[], counts and computedAt. One item is one occurrence, not one rule with rows: two connectors needing a reconnect are two items sharing a key. key discriminates data; type (problem | live | procedure here, the vocabulary also holds recommendation) and severity (error | warning | info) are independent axes; every item carries summary, a fixed 64-char English sentence per key that is never display copy; tags are closed and constant per key. Nothing is persisted and there is no inbox — an item disappears when the fact resolves. Dismissal is the client's business: the backend declares a policy in dismiss and stores none of it. Filter with severity, scope, channel, tags, notTags and country; an unknown value is a 400, and counts is always computed over the whole list.
**Search keywords:** attention, what needs attention, alerts, what is broken, why did my campaign not go out, expired card, low balance, unpaid invoice, overdue invoice, connector disconnected, stale datasource, reconnect, pending connection, scheduled campaign underfunded, insufficient funds, auto charge disabled, api blocked, rejected registration, pending review, regulation deadline, dismiss, snooze, counts, occurrence, deadline, dueAt, health check, is everything ok, summary, recommendation type, recommendations endpoint
**Related pages:** /platform-api/product-api/project-setup/readiness, /platform-api/product-api/project-setup/channel-readiness, /platform-api/product-api/project-setup/recommendations
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/attention/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/attention.md (Markdown)
The readiness endpoints answer "what is left to configure". This one answers the other half: **what has broken, or is about to, and by when.** An expired card, a connector that has stopped bringing contacts in, a campaign scheduled for tomorrow that the balance will not cover.
It is the endpoint behind a health check — the one an integration calls to answer "is everything OK with this account", and the one an assistant reads before claiming that it is.
[`GET /v1/project/{project}/attention` - Everything demanding attention for a project and the organization behind it, worst first.](/platform-api/product-api/reference)
Any Product API token with access to the project can read it; no extra scope is required. Note the range: the report covers the project **and the organization behind it**, so a token scoped to one project still sees the organization's expired card. It has to — the card is what stops that project from sending.
## The boundary with readiness
The two engines split the work and never report the same fact twice.
| | Question | Example |
| -------------------------------------------------------------- | -------------------------------- | ------------------------------------- |
| [Readiness](/platform-api/product-api/project-setup/readiness) | What is left to configure? | "You have no legal profile yet" |
| **Attention** | What has broken, or is about to? | "The card on file expired on 1 April" |
The rule that separates them is worth holding on to, because it is the one that decides where a new fact lands: **absence is always readiness.** For an attention item to exist, the thing has to exist *and* be broken. There is no expired card without a card, no disconnected connector without a connector, no underfunded campaign without a campaign.
The practical consequence is the one to design your UI around: **a project that has just finished signup has a full checklist and no alerts at all.** You will never render "your balance is low" next to the step asking the customer to add funds. If you ever see an error on a brand-new project, that is a bug on our side, not an edge case to handle.
> **Note**: There is one deliberate overlap, and it is a handover rather than a duplicate. A rejected registration is reported here as news, with its cause, for 30 days; at the same time readiness lists that country under `recommended-registrations`, because a rejected registration is not coverage. Both statements are true and they say different things. See [what moved here](/platform-api/product-api/project-setup/channel-readiness#what-moved-to-the-attention-endpoint).
## The report
```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT/attention" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
```json
{
"entity": {
"items": [
{
"id": "3f2a1b0c9d8e7f65",
"key": "expired-card",
"scope": "org",
"channel": null,
"type": "problem",
"severity": "error",
"tags": ["billing", "entity:organization", "expired-payment-method"],
"dismiss": { "mode": "snooze", "days": 14 },
"entities": [
{ "type": "card", "id": "pm_1a2b3c", "name": "Visa" },
{ "type": "organization", "id": "5f3a9b2c4d1e0f00112233ab", "name": "ACME" }
],
"data": { "brand": "Visa", "last4": "4242", "expiresOn": "2026-03", "default": true },
"startsAt": "2026-04-01T00:00:00Z"
},
{
"id": "91bc44de20fa5c37",
"key": "scheduled-campaign-underfunded",
"scope": "project",
"channel": null,
"type": "problem",
"severity": "error",
"tags": ["billing", "entity:campaign", "insufficient-funds"],
"dismiss": { "mode": "none" },
"entities": [
{ "type": "campaign", "id": "6512ab34cd56ef7890123456", "name": "Black Friday" }
],
"data": { "estimatedCost": 210.0, "shortfall": 60.0, "currency": "EUR" },
"dueAt": "2026-09-06T09:00:00Z"
}
],
"counts": {
"error": 2,
"warning": 1,
"info": 0,
"byKey": { "expired-card": 1, "scheduled-campaign-underfunded": 1, "pending-connection": 1 }
},
"computedAt": "2026-09-05T09:41:12Z",
"truncated": false
}
}
```
`items` arrives sorted worst first, then soonest deadline, then key. Render it in the order it arrives.
## Nothing is stored
There is no inbox behind this. No table of alerts anyone marks as read, no unread count to reconcile: **every call recomputes the whole thing from the account's current data.**
That has one consequence that should shape how you build on it: **an item disappears when the fact resolves, and only then.** The expired-card item goes when the customer adds another card. The half-finished connection goes when the data source is created. Nobody has to remember to turn anything off, and nothing can be left behind pointing at a problem that no longer exists.
It also means the inverse is guaranteed, which is the part that matters if an assistant is reading this: **if an item is in the response, it is true right now.** A stored inbox drifts from reality and eventually lies — it holds "card expired" from three weeks ago, the customer replaced the card, and someone opens a conversation about a problem that does not exist. That cannot happen here.
`computedAt` travels for exactly that reason. It is always "now" at assembly time, but a client that fetches once and renders for the next ten minutes is holding an answer that is ten minutes old and needs to know it.
## An item is one occurrence
**One item is one occurrence of one rule, not one rule with a list of rows.** Two connectors that both need reconnecting are two items sharing a `key`. If you want them rendered as one block, group by `key` yourself.
- `id` — `string`
Content fingerprint of this occurrence — stable across polls while it is the same problem, and defined per key. An expired card is identified by brand, last4 and expiry rather than by the card's id, so re-adding the same card stays dismissed while a *different* expired card resurfaces. Nothing volatile goes into it, so it is safe to use as a React key or as the handle for a stored dismissal.
- `key` — `string`
Stable rule identifier, and the discriminator for `data`. **The API ships no copy**: the label and its translation are yours.
Render an unknown key rather than dropping it — the backend can ship a new one ahead of its consumers, and a silently discarded item is a problem the customer never hears about.
- `scope` — `string`
`org`, `project` or `channel`. An `org` item affects every project of the organization, so the same expired card shows up whichever project you asked about.
- `channel` — `string | null`
`sms` or `rcs`, set only on a channel-scoped item; `null` otherwise.
- `type` — `string`
`problem`, `live` or `procedure` — what the item *is*, and the axis is **who acts**: `problem` waits on the customer, `live` is the platform working right now (sending, importing) and moves in seconds, `procedure` is a third party reviewing (a carrier, a regulator) and moves in days or weeks. The vocabulary also holds `recommendation`, which only ever appears on [recommendations](/platform-api/product-api/project-setup/recommendations).
The vocabulary holds a third value, `recommendation`, which **never appears on this endpoint**: the catalogue is partitioned by type and recommendations are served by their own report. Narrow on the two you get here, but do not assert the enum has only two members. See [Recommendations](/platform-api/product-api/project-setup/recommendations).
- `severity` — `string`
`error`, `warning` or `info` — how to react: act, act soon, just know.
- `summary` — `string`
One English sentence, at most 64 characters, saying what this key *is*. Declared once per key and identical on every item of it. Present on every item. See [below](#summary-is-not-your-copy).
- `tags` — `string[]`
A closed vocabulary, **constant per key**, of which exactly one is a domain tag (`billing`, `compliance`, `delivery`, `data`, `audience`, `setup`).
- `dismiss` — `object`
The rule's hiding policy — `{ mode }`, plus `days` when the mode is `snooze`. See [below](#the-dismiss-field).
- `entities` — `object[]`
What the item is about, so you can link straight to it without parsing copy. Each carries `type`, `id` and, when there is one, `name`. `name` is the entity's own display name — customer-written text, never personal data.
- `data` — `object`
Rule-specific payload, **discriminated by `key`**. Absent when the rule carries none.
- `state` — `string`
`running` or `finished`. Optional on any item — it is not tied to `type`. Today it is emitted by `pending-review`, which is `procedure`, and by both `live` items, `campaign-sending` and `datasource-syncing`; it spans the two types precisely because it belongs to neither. Authoritative over `endsAt`.
- `progress` — `object`
`{ done, total }`. Optional on any item, exactly like `state`. Today only the two `live` items emit it, and only while the work is measurable — an item can travel with `state` and no `progress`, which the contract allows.
- `startsAt` — `string`
When the underlying fact began. Absent when unknown.
- `endsAt` — `string`
When it ended, or will end, if it is bounded.
- `dueAt` — `string`
The deadline after which the consequence lands.
### `type` and `severity` are independent axes
They answer different questions and you need both. `expired-card` and `expiring-card` are both `problem`, and one is `error` while the other is `warning` — the same card, on either side of the date. And `info` is not a type: it is what `pending-review` (`procedure`) and `campaign-sending` (`live`) both carry. Neither axis follows from the other, and neither follows from `tags`: the tags are constant per key precisely so they can be used for filtering and grouping, never as a severity signal in disguise.
**Severity is declared once per key and never varies by occurrence.** An import that has been stuck for two hours is not a `datasource-syncing` item turned `warning`; it is a different key, the day that key exists.
### `summary` is not your copy
Every item carries a `summary`: one English sentence, capped at 64 characters, declared once per key. It says what the key *is* — "The saved payment card has expired" — and it is identical on every occurrence of that key.
It exists for the consumers that have no glossary and no localisation layer: a script, an agent, a support tool that has just met a key it does not know. It is what lets an unknown key still be reported to a human as something rather than as a bare identifier.
**A dashboard should never render it.** Copy is yours, per key, in your user's language, and nothing situational is in there anyway — the numbers, dates and names are in `data` and `entities`, typed. A surface that prints `summary` will show English to a Spanish customer and will show the same sentence for two occurrences that differ in every detail that matters.
### Switch on `key`, never on which fields are missing
`data` is discriminated by `key`. Narrow on the key first and the payload shape narrows with it. Inferring the payload from which fields came back absent breaks the moment a rule gains a field.
### Timestamps are absolute, and absent rather than null
Every timestamp is absolute UTC, and **omitted rather than sent as `null`** when it is unknown. Nothing relative is ever sent — no "12 days left" — because the payload outlives the instant it was computed: a countdown stops being true as soon as anything stores it or repeats it a few minutes later. Compute the relative form at render time, against the report's `computedAt`.
> **Tip**: `dueAt` is where most of the value is. An underfunded campaign carries its send time, an expiring card carries the expiry, a regulator deadline carries the date the country closes. Sorting or badging on `dueAt` is what turns this endpoint from a status list into a warning that arrives while the customer can still act.
## The `dismiss` field
**There is no dismiss endpoint, and the backend stores nothing.** If your surface offers a way to hide an item, you implement it and you store it — the API never learns it happened.
What the field gives you is the rule's policy, so that every surface makes the same call rather than each inventing one. It is constant per `key`.
| `mode` | What you may offer |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `none` | Nothing. Hiding it would only delay the consequence — an overdue invoice, a blocked API. |
| `dismiss` | Hide with no expiry. Bring it back when the content changes, which is what `id` tracks. |
| `snooze` | Hide for `days`, then bring it back even if nothing changed. For facts that worsen on their own while their fingerprint stays identical — an expired card. |
A snooze is a **floor, not a ceiling**: if the content changes inside the window the `id` changes, and an item you keyed on `id` reappears early on its own. Since the expiry is computed by whoever stores the dismissal, changing `days` here is not retroactive over what you already stored.
> **Warning**: **This response is never filtered by anyone's dismissals.** It is the full list, always. That is what makes it safe to build a health check, a support tool or an assistant on top of it: none of them can be blinded by a person who chose not to look at something. It also means that if you render it raw, you render everything — hiding is yours to add.
## The catalogue
Sixteen keys, grouped by the domain tag they carry. The condition is what makes each one appear.
### `billing`
| `key` | Appears when |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `expired-card` | A stored card's expiry date has passed. |
| `expiring-card` | The same card, 30 days before. |
| `low-balance` | Below the organization's configured threshold, and **only if money ever entered the account**. Zero on a brand-new account is not low balance, it is an unfunded account — that is readiness. On postpaid it looks at consumed allowance instead. |
| `subscription-past-due` | A paid subscription's charge failed and the subscription is not frozen. |
| `unpaid-invoice` | A charge is outstanding with an invoice, due date still ahead. |
| `overdue-invoice` | The same, past its due date. An invoice with no due date is **never** marked overdue. |
| `auto-charge-disabled` | Prepaid only, only when the project has active automations or flows, and only when the organization is actually able to turn it on. |
| `scheduled-campaign-underfunded` | The cost of the scheduled campaigns exceeds available balance. One item per campaign that is not covered, carrying its send time as `dueAt`. |
| `campaign-unpaid` | A campaign went unpaid during its send. Shown for 30 days. |
### `data`
| `key` | Appears when |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending-connection` | The customer completed an integration's authorization and never created the data source. The abandoned authorization is the thing that exists. |
| `datasource-reconnect` | The data source exists and its access has dropped or expired, so it is bringing nothing in. |
| `stale-datasource` | Seven days without bringing data in, and **only if it ever synced**. One that never synced is unfinished setup, not a fault. |
### `delivery`
| `key` | Appears when |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `api-rate-limit-blocked` | The account carries a manually set negative limit, which rejects every call. **Never appears on a normal account**: if you see it, somebody set it. |
### `compliance`
| `key` | Appears when |
| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rejected-registration` | A carrier or a regulator turned a registration down. Shown for 30 days, then the fact returns to readiness as a country still to register. |
| `pending-review` | A registration was submitted and is waiting on a carrier or a regulator. The catalogue's only informative entry — nothing is broken and there is nothing to do. |
| `regulation-deadline` | Only in the 30 days before the date, and only for countries the customer **can send to today**. It says "on the 15th you stop being able to send to Spain", which is not the same as "you have not registered Spain" — that is readiness. |
> **Note**: `rejected-registration` and `pending-review` kept the key names they had as channel-readiness warnings before 2026-09-05, so a switch written against the old endpoint still matches. What changed is the shape around them: an occurrence with `entities` and `data`, not a warning with `rows`.
## Filtering
Six optional query parameters. All the comma-separated ones are OR within a parameter and AND across parameters.
- `severity` — `string`
Comma-separated. Keeps only items of these severities. `error,warning`
- `scope` — `string`
Comma-separated: `org`, `project`, `channel`.
- `channel` — `string`
`sms` or `rcs`. Channel is deliberately not a tag value, because it varies per occurrence — it has its own parameter instead.
- `tags` — `string`
Comma-separated. Keeps items carrying **all** of them.
- `notTags` — `string`
Comma-separated. Drops items carrying **any** of them. Applied after `tags`.
- `country` — `string`
ISO 3166-1 alpha-2. Country, like channel, is not a tag value.
Two things are worth knowing before you build on this.
**Filtering is free, and it does not save you a call.** The list is assembled first and filtered afterwards, so a filtered request costs exactly what an unfiltered one costs. Filter for the sake of your own rendering, not to make the endpoint cheaper.
**`counts` is always computed over the whole list**, never over the filtered one. A surface that asked for one tag still learns there are errors elsewhere, without paying for a second call to find out.
> **Warning**: An unknown value in any filter is a `400`, not a silent empty list. That is on purpose: a typo in a tag name should fail loudly rather than look exactly like a healthy account.
## `truncated`
`truncated: true` means the engine hit its per-report ceiling and the list you got is not everything. Treat it as "at least this much is wrong", and never render a total from `items.length` when it is set — `counts` is the number to trust.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/recommendations
# Recommendations
The third question an account asks, after what is left to configure and what has broken: what is worth doing next. Suggestions for a project that already works, best first, from the same engine as attention and served as its own report.
**Language:** en
**Audience:** developer
**TLDR:** GET /v1/project/{project}/recommendations returns items[], counts and computedAt. Same engine and same item shape as attention, a separate endpoint so neither report's counts can contain the other's items. Three differences that matter: key is an OPEN string here because the catalog is meant to grow without a spec edit; absence is the point rather than forbidden, held back by a gate — nothing is suggested until the project has contacts and a sender; and every item is severity info, ranked by priority (high | medium | low) instead. dismiss is always { mode: dismiss }: a suggestion must never come back on a timer. No filters, no debug parameter, 60 requests a minute, and it changes over days rather than minutes — do not poll it.
**Search keywords:** recommendations, what should I do next, next steps, suggestions, grow, priority, high medium low, open key, catalog, first-segment, sales-tracking, automate-data-entry, plan-upgrade, upgrade-to-rcs, expand-sender-coverage, sender coverage, register a sender in more countries, seasonal-campaign, black friday, cyber monday, christmas, halloween, valentine's day, summer sales, winter sales, occasion, summary, dismiss a recommendation, why do I see no recommendations, gate, audience and channel, attention vs recommendations
**Related pages:** /platform-api/product-api/project-setup/attention, /platform-api/product-api/project-setup/readiness, /platform-api/product-api/project-setup/channel-readiness
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/recommendations/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/project-setup/recommendations.md (Markdown)
[Readiness](/platform-api/product-api/project-setup/readiness) answers "what is left to configure". [Attention](/platform-api/product-api/project-setup/attention) answers "what has broken". Neither answers the question a customer starts asking the day after signup and never stops: **what is worth doing next.**
A project that finished its checklist and has nothing broken used to get silence from both reports — precisely when there was most to say to it. That is what this endpoint carries.
> **Warning**: **Three different things in this API share the word.** They are not related and no page mixes them:
>
> - **`recommendation`** — the item `type` on this page: a next step worth taking. This endpoint.
> - **`recommended-registrations`** — a [readiness warning key](/platform-api/product-api/project-setup/channel-readiness#warnings): countries a sender ought to be registered for and is not. A coverage gap, not a suggestion, and it lives in the channel report.
> - **`recommendations`** — a field in the MCP server's channel-readiness tool, which lists exactly those registrations. Same subject as the warning above, nothing to do with this endpoint.
>
> If you are looking for countries to register, you want the [channel report](/platform-api/product-api/project-setup/channel-readiness), not this one.
[`GET /v1/project/{project}/recommendations` - What is worth doing next in a project that already works, best first.](/platform-api/product-api/reference)
```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT/recommendations" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
```json
{
"entity": {
"items": [
{
"id": "8f2c1a4e9b7d3506",
"key": "first-segment",
"scope": "project",
"channel": null,
"type": "recommendation",
"severity": "info",
"priority": "high",
"summary": "Contacts are loaded but no segment exists yet",
"tags": ["audience"],
"dismiss": { "mode": "dismiss" },
"entities": [],
"data": { "contacts": 1250 }
},
{
"id": "3a91c07f5be2d148",
"key": "sales-tracking",
"scope": "project",
"channel": null,
"type": "recommendation",
"severity": "info",
"priority": "high",
"summary": "No sales are recorded; campaign revenue is unknown",
"tags": ["data"],
"dismiss": { "mode": "dismiss" },
"entities": [],
"data": { "observedWindowDays": 90 }
}
],
"counts": {
"high": 2,
"medium": 0,
"low": 0,
"byKey": { "first-segment": 1, "sales-tracking": 1 }
},
"computedAt": "2026-09-06T09:41:12Z",
"truncated": false
}
}
```
## Same engine, separate report
This is not a second engine. It is the attention engine's catalog partitioned by item type: same rule mechanism, same item shape, same fingerprinting, same dismissal contract. Building it twice would have been paid for three times — in code, in documentation and in a second contract to keep in step with the first.
What is deliberately *not* shared is the response. **`counts` is computed over the whole list in both reports**, so a single endpoint returning both kinds and letting you filter would have counted suggestions as problems for everyone who read `counts` before reading `items`. Two reports, two sets of counts, no way to conflate them.
The same reasoning applies on the surface: recommendations never appear in a "needs attention" list. A suggestion rendered next to a failure reads as a failure.
> **Note**: If you already consume [attention](/platform-api/product-api/project-setup/attention), almost everything you wrote is reusable here. The three differences worth reading before you do are [the open `key`](#key-is-open-here), [the gate](#the-gate-nothing-until-the-project-has-started) and [`priority` instead of `severity`](#priority-not-severity).
## `key` is open here
On an attention item, `key` comes from a closed enum. **Here it is an open string, and that is a contract commitment rather than an oversight.** This catalog is meant to grow: a new suggestion must be able to ship without a spec edit landing first in every consumer.
So write the same defensive rendering you would write for a webhook you do not control:
- **Never `switch` without a default.** An unrecognised key is a normal event, not an error.
- **Never drop an unknown item.** It is a real suggestion for a real project; a silently discarded one is advice the customer never gets.
- **Treat `data` as untyped until you have matched the key.** It is discriminated by `key`, exactly as in attention.
The keys that exist today travel in `attention-catalog.json`, published beside the TypeScript definitions — read them from there rather than hard-coding a list that will be stale.
## The gate: nothing until the project has started
**No recommendation is produced until the project holds contacts and has at least one sender.** Before that the response is an empty list with zeroed counts, and that is the correct answer rather than a missing feature.
The reason is that the checklist already owns that phase and does it better. Without the gate, a project created five minutes ago would be handed a list of things it is not doing yet — which is the readiness report's job.
> **Warning**: **The gate is asked of reality, never of the checklist.** It reads actual contacts and actual senders, not whether the corresponding readiness steps are marked complete. Those are different questions: readiness steps can be skipped, a skip is shared across the organization, and the step catalog itself differs by project type. A gate that read the checklist would answer differently for two projects in identical states.
### Absence is the point here
The rule that governs [attention](/platform-api/product-api/project-setup/attention#the-boundary-with-readiness) is that **absence is always readiness** — for an item to exist, the thing has to exist *and* be broken.
Recommendations invert it on purpose. *"You have contacts and no segment yet"* is an absence, and it is exactly the thing worth saying. These are the only rules in the engine allowed to fire on something not existing; the gate is what keeps that from turning into noise.
## An item
The shape is the [attention item](/platform-api/product-api/project-setup/attention#an-item-is-one-occurrence) with one field added and several narrowed. Only the differences are described here.
- `type` — `string`
Always `recommendation`. It never appears on the attention endpoint, and `problem`, `live` and `procedure` never appear on this one — the catalog is partitioned by this field.
- `severity` — `string`
Always `info`. It is kept so the item shape stays identical to an attention item, not because it carries information. Rank by `priority`.
- `priority` — `string`
`high`, `medium` or `low`, worst first. Declared per key and raised per occurrence when the situation warrants it. **This is the only axis the list is ordered by.**
- `summary` — `string`
One English sentence, at most 64 characters, saying what the key *is*. Declared once per key and identical on every item of it. See [`summary`](/platform-api/product-api/project-setup/attention#summary-is-not-your-copy) on the attention page — the same field, the same warning: it is not display copy.
- `dismiss` — `object`
Always `{ "mode": "dismiss" }`. Never `snooze`, and never `none`. See [Dismissal](#dismissal-never-on-a-timer).
- `dueAt` — `string`
The date the suggestion stops being useful, when it has one. Today only the seasonal rule carries it.
`id`, `key`, `scope`, `channel`, `tags`, `entities`, `data`, `startsAt` and `endsAt` behave exactly as they do on an attention item. No item on this endpoint carries `state` or `progress`: those fields are optional on any item, and no recommendation emits them.
### `priority`, not `severity`
An attention item ranks by `severity` because the question is how hard to react. Every recommendation is `info` — nothing here is wrong — so ranking by severity would put six identical items in an arbitrary order.
`priority` answers the other question: **of the things worth doing, which first.** Items arrive sorted by it and then by the order the catalog declares, so a surface that renders the first three renders the three that matter and does not need to sort.
## Dismissal: never on a timer
Every recommendation is a plain `dismiss`, and the absence of `snooze` from this catalog is a decision rather than a gap: **a suggestion the customer declined must not come back in thirty days as if nobody had asked.** An attention item may legitimately return on a timer — an expired card gets worse while looking identical — but nothing here does.
As in attention, [the backend stores nothing](/platform-api/product-api/project-setup/attention#the-dismiss-field). If your surface offers hiding, you implement it and you store it, keyed on `id`.
What brings a dismissed recommendation back is therefore its fingerprint, and each rule chooses that deliberately:
- A rolling measurement carries no date, so it stays hidden until the underlying situation actually changes.
- The seasonal rule carries the year, so next year's Black Friday is a different `id` and legitimately reappears. That is the one intended exception to "declined once, gone".
> **Warning**: **This response is never filtered by anyone's dismissals**, exactly like attention. If you render it raw you render everything, including what the customer hid in the dashboard — the two surfaces are not synchronised, and neither one is blinded by the other.
## The catalog today
Eight keys, and the list is expected to grow — see [`key` is open here](#key-is-open-here).
| `key` | `priority` | `scope` | Domain tag | `data` | Fires when |
| ------------------------ | ---------- | --------- | ---------- | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `first-segment` | `high` | `project` | `audience` | `contacts` | There are contacts and no segment, so every campaign goes to everyone. |
| `sales-tracking` | `high` | `project` | `data` | `observedWindowDays` | No sale has been recorded in the observed window, so campaign return cannot be computed. Requires an authored campaign. |
| `automate-data-entry` | `medium` | `project` | `data` | `businessType` | Every data source the customer created is a CSV upload, and there is at least one. Anything else they set up silences it — a native connector, a webhook, or an Ingest API source (the panel calls that one "Instasent API"). The three invisible system rows (`api`, `instasent`, `defaults`) never count, in either direction. |
| `plan-upgrade` | `medium` | `org` | `billing` | `payingMonths` | The organization has been paying month after month on the free plan. |
| `expand-sender-coverage` | `medium` | `channel` | `delivery` | `countries`, `audienceShare` | A sender is not registered in countries that together hold more than 10% of the contacts with a mobile phone. One item per sender, per channel. |
| `upgrade-to-rcs` | `low` | `project` | `delivery` | — | The project has SMS senders and no RCS agent, has already authored a campaign, and RCS is available to the organization. |
| `flow-template` | `medium` | `project` | `delivery` | `templateKey`, `title`, `eventType`, `datasourceId` | A ready-made flow fits an event the project already receives, and no flow was created from it. One item per template, at most three: exactly the ones [`GET /flow/templates`](/platform-api/product-api/flows/templates) flags `recommended`. Only where flows are available to the project. |
| `seasonal-campaign` | `low` | `project` | `delivery` | `occasion`, `eventName`, `eventNameEs`, `windowDays` | A calendar date worth a campaign is approaching. Seven occasions today, each from 30 or 45 days out. One item per occasion. |
Four of them are worth a note each:
- **`sales-tracking` does not mean the customer sells nothing.** Every project has an effective definition of a sale, so there is no "tracking is off" state to report — the item says only that no sale was recorded in the last `observedWindowDays`. The common cause is that the sales *are* arriving, under a different event than the one counted: an order payment, a payment, a lead, a won deal. The action it points at is reviewing which event counts as a sale, not setting anything up.
It is also **silent rather than pessimistic**: when the revenue signals cannot be read at all, the rule emits nothing. "We could not ask" is never published as "they sell nothing", so the absence of this item is not evidence of sales.
- **`expand-sender-coverage` comes once per sender, per channel.** `channel` is `sms` or `rcs`, and the sender is the `sender` entity. The countries are the ones the [channel readiness report](/platform-api/product-api/project-setup/channel-readiness#warnings) recommends registering for that sender, kept only where the audience is real (at least 1% and 25 contacts) and listed largest audience first. `audienceShare` is their combined share of the contacts with a mobile phone, from 0 to 1 and unrounded. A country recommended because a regulator requires it counts with its audience like any other. Dismissing it holds until that sender's list of countries changes; a change in the percentages alone does not bring it back.
Its absence is not proof of full coverage: when the audience figures are not available at that moment, the item is left out of that response rather than guessed.
- **`plan-upgrade` is the only one that reads the organization**, so it is `scope: "org"` and carries an `organization` entity. It is also the only one that skips the gate and the only one that applies to `api_sms` (A2P Messaging) projects, which have no audience and no authored campaigns; on such a project it is the only recommendation you will ever see.
- **`seasonal-campaign` comes once per occasion.** `occasion` is a stable id: `black_friday`, `christmas` and `summer_sales` appear 45 days before the date; `cyber_monday`, `winter_sales`, `valentines_day` and `halloween` appear 30 days before. `windowDays` is that lead time. The list can grow, so handle an `occasion` you do not recognise gracefully, for example by falling back to `eventName`. `eventName` is the occasion's name in English and `eventNameEs` in Spanish; when there is no Spanish name, `eventNameEs` carries the English one, so it is always a string.
The date of the occasion is the item's `dueAt`: midnight of that day in the project's timezone, expressed in UTC. For a project in Madrid, Cyber Monday 2026 arrives as `2026-11-29T23:00:00Z`, so convert it to the project's timezone before taking the calendar day. Several occasions can be active at once (in November, Black Friday, Cyber Monday and Christmas are three separate items). Each one starts at `low` and rises to `medium` within 14 days of its date.
> **Note**: **`upgrade-to-rcs` is `scope: "project"` and still sets `channel: "rcs"`.** The scope says whose situation it is; `channel` says what it is about. Do not infer one from the other.
## No filters, and do not poll it
The endpoint takes no query parameters at all — no `severity`, no `scope`, no `tags`, none of the six that [attention accepts](/platform-api/product-api/project-setup/attention#filtering). The list is short by construction and ordered for you; filtering it server-side would only give `counts` a way to disagree with `items`.
There is **no `debug` parameter** on the Product API. The dashboard has a sample view so a developer can render every card, and it stays there: an external agent handed synthetic items would repeat them to a customer as fact.
Rate limit is **60 requests a minute**, and it is generous rather than tight — this report moves over days, not minutes. Fetch it when a surface opens, not on an interval. `computedAt` tells you how old the answer you are holding is; `truncated` tells you the list was capped, which today's catalog does not reach in practice.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/overview
# Audience
The audience is the project's single, merged view of the people it knows: contacts assembled from every data source, the attributes and events they carry, the filters that select them, the segments that keep those filters, and the endpoints that count and retrieve them.
**Language:** en
**Audience:** developer
**TLDR:** A project has ONE audience: every data source feeds it and contacts merge into a single consolidated record per person. You select contacts with the audience query filter (events with the event query filter), measure what the data actually holds with the two probes, get numbers and rows from /audience/count, /audience/search and /audience/scroll, and keep a filter as a named segment. Consent and channel reach are decided when something is sent, never stored inside a segment.
**Search keywords:** audience, unified audience, audience model, CDP, customer data platform, single customer view, consolidated contact, merged contact, who is in my project, how many contacts, audience overview, reach, reachable, audience size, contacts, data sources, datasource, segments, events, attributes, audience API, work with the audience, integrate the audience
**Related pages:** /platform-api/product-api/audience/contacts-and-attributes, /platform-api/product-api/audience/query-filter, /platform-api/product-api/audience/segments
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/overview/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/overview.md (Markdown)
Instasent is a customer data platform, and the audience is the thing it is a platform for. Everything else in the Product API either feeds the audience, asks it a question, or acts on the answer. A project holds exactly **one** audience: not a set of lists you maintain in parallel, but a single merged view of every person the project knows, assembled from every data source that ever sent that project data.
This section is the working guide to that audience over the API: what it is made of, how to ask it questions, how to check that the answer means what you think it means, and how to keep a question you want to reuse.
## The model
```mermaid
flowchart TD
DS1[Data source: CRM]
DS2[Data source: e-commerce]
DS3[Data source: Ingest API]
DSC[Datasource contacts
one record per source]
AUD[Audience contact
one consolidated record per person]
EV[Events
what the person did]
QF[Query filter
selects contacts]
SEG[Segment
a saved filter]
CMP[Campaigns and automations]
DS1 --> DSC
DS2 --> DSC
DS3 --> DSC
DSC -->|merged by the project's merging attributes| AUD
EV -->|attached to the person| AUD
AUD --> QF
QF -->|saved with a name| SEG
QF --> CMP
SEG --> CMP
```
Five nouns carry the whole section:
- **Data source** is a stream of contacts and events feeding one project. A project can have many, and each keeps its own original records.
- **Datasource contact** is the record exactly as it arrived in one source.
- **Audience contact** is the consolidated projection of every datasource contact that turned out to be the same person. This is what you query, count and message. It is not a copy of one source's record: it is the merged result, and no single field on it is guaranteed to have come from any particular source.
- **Event** is an immutable record of something the person did. Events hang off the audience contact, so one person has one timeline no matter how many sources reported parts of it.
- **Segment** is a saved filter, not a stored list. It holds a question, and the answer is recomputed when it is used.
> **Note**: A segment does not contain contacts. It contains the filter that selects them, so its membership changes as the audience changes. That is the single most common wrong assumption about this API, and it is worth fixing before writing any code: see [Segments](/platform-api/product-api/audience/segments).
## What lives in this section
- [Contacts and attributes](/platform-api/product-api/audience/contacts-and-attributes) - How records from several sources become one contact, and what system and custom attributes it carries.
- [Events](/platform-api/product-api/audience/events) - The event model: types, parameters, the project's catalogue, and what actually arrives.
- [Query filter](/platform-api/product-api/audience/query-filter) - The grammar that selects contacts: attribute, event and segment conditions.
- [Event query filter](/platform-api/product-api/audience/event-query-filter) - The grammar that selects events directly, with its own aggregation surface.
- [Probes](/platform-api/product-api/audience/probes) - Measure what the audience actually contains before you filter it. Probe first, then filter.
- [Counting and retrieval](/platform-api/product-api/audience/counting-and-retrieval) - Count, search, scroll: which endpoint answers which question, and what each returns.
- [Segments](/platform-api/product-api/audience/segments) - Keep a filter as a named segment, read it back, audit where it is used.
## How big is my audience
Before composing anything, the cheapest question has its own endpoint. `GET /project/{project}/audience/overview` returns the project's total contacts and, for every channel the project can send on, how many of them are actually reachable under the project's own consent policy, together with what each other policy would reach.
[`GET /project/{project}/audience/overview` - Total contacts plus reachable contacts per channel, and the full policy ladder.](/platform-api/product-api/reference)
```bash
curl "$BASE/audience/overview" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Two things about the answer. It carries aggregate numbers only, with no contact rows, so it is not restricted by the project's data policy and needs nothing beyond `PROJECT_AUDIENCE_READ`. And its counters are cached, with an age reported in `computedAgo` that you should narrate along with the figure rather than presenting it as a live total. Channels the project cannot send on simply do not appear.
Use it when the question is "what is my audience" or "what would I gain by asking for opt-in". Use [`/audience/count`](/platform-api/product-api/audience/counting-and-retrieval) when the question is about a specific filter.
## Three rules worth knowing before you start
### The contact is a projection, not a record
An audience contact is built from every source that touched that person, and the project's priority rules pick the winner when two sources disagree about the same attribute. So "this contact came from the CRM" is not a meaningful statement about a value: the contact came from all of them. Narrowing anything by `datasource` selects contacts a source **contributed to**, never the values it supplied. The consequences are worked through in [Contacts and attributes](/platform-api/product-api/audience/contacts-and-attributes).
### Consent is not part of the audience question
Who the contacts **are** and who may be **messaged** are separate questions, resolved at different moments. A filter and a segment answer the first. Reach under a channel's consent policy is resolved when something is sent, and it is available on demand through the `filterCompliance` key of a count. Never bake consent into a saved segment.
### Probe before you filter
A condition on an attribute almost nobody filled, or on a value spelled differently in the data, is a perfectly valid filter that selects nobody. It returns `200`. Nothing errors. The two [probes](/platform-api/product-api/audience/probes) exist to close that gap, and reaching for them costs one request.
## Scopes
The whole section runs on the audience scopes described in the [Product API guide](/platform-api/product-api/guide#tokens-and-scopes). In short:
| You want to | Scope |
| -------------------------------------------------------- | --------------------------------------------- |
| Read a contact, read segments, count, probe | `PROJECT_AUDIENCE_READ` |
| Scroll contacts, or scroll a segment | `PROJECT_AUDIENCE_LIST` (plan-gated) |
| See phone, email, country and names on returned contacts | `PROJECT_AUDIENCE_DATA_BASIC` (plan-gated) |
| Read events on a contact | `PROJECT_AUDIENCE_DATA_EVENTS` (plan-gated) |
| Save a filter as a segment | `PROJECT_SEGMENT_WRITE` |
| Run your own aggregations | `PROJECT_AGGREGATIONS` (granted by Instasent) |
> **Warning**: Scopes and plan are two gates, not one. A scope your token carries still returns redacted data if the organization's subscription does not include it. Design against **Basic** access and verify what actually comes back before assuming a field is there.
## What's next
- **[Contacts and attributes](/platform-api/product-api/audience/contacts-and-attributes)**: the merge model and the attribute catalogue, which is where most integration surprises come from.
- **[Query filter](/platform-api/product-api/audience/query-filter)**: the grammar, once you know what you are filtering on.
- **[Full API Reference](/platform-api/product-api/reference)**: every audience, event and segment endpoint.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/contacts-and-attributes
# Contacts and attributes
How records arriving from several data sources become one audience contact, what the merging attributes decide, and how to read the project's live attribute catalogue: system versus custom attributes, data types, uniqueness and event-derived fields.
**Language:** en
**Audience:** developer
**TLDR:** An audience contact is the consolidated projection of every datasource contact identified as the same person. Merging is driven by the project's unique attributes (primary _user_id, plus extras such as _email and _phone_mobile); when sources disagree the project's priority rules pick the winner. GET /project/{project}/specs/attributes returns the live catalogue with uid, dataType, unique, custom, readonly and eventBased. A custom attribute uid never starts with an underscore.
**Search keywords:** contact, contacts, audience contact, datasource contact, merge, merging, merged contacts, deduplication, dedupe, duplicate contacts, identity resolution, single customer view, consolidated, projection, unique attribute, merging attributes, _user_id, user id, attributes, attribute catalogue, attribute catalog, custom attribute, system attribute, data type, eventBased, readonly, specs attributes, which fields exist, what attributes does my project have, data source, datasource, _datasources, provenance, where did this value come from
**Related pages:** /platform-api/product-api/audience/probes, /platform-api/product-api/audience/query-filter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/contacts-and-attributes/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/contacts-and-attributes.md (Markdown)
Everything you filter, count or message operates on the **audience contact**: one record per person, per project. It is not the record any single system sent you. It is what the project built by deciding that several incoming records described the same human being, and then reconciling them. Getting that distinction right up front removes most of the surprises an integration hits later.
## From datasource contact to audience contact
Each data source keeps its own records exactly as they arrived: those are **datasource contacts**. The project then merges them into the audience by the **merging attributes** configured on it.
#### 1. A record arrives
Your CRM sends a person with `_user_id: "12345"` and an email. The e-commerce source sends a person with the same `_user_id` and a phone number.
#### 2. The project looks for a match
Merging is driven by the attributes marked **unique** on the project. `_user_id` is the primary one; a project typically configures extras such as `_email` and `_phone_mobile`. A match on **any** of them triggers a merge.
#### 3. The values are reconciled
When two sources disagree about the same attribute, the project's priority rules decide the winner. The Ingest data source takes precedence by default.
#### 4. The timelines join
Every event from every merged record now hangs off the one audience contact, so a person has a single timeline regardless of how many systems reported parts of it.
The result keeps a trail back to its origins: `_datasources` lists the sources the contact was built from, and `_ds_contact_ids` the underlying datasource contact ids. Use them to trace a contact back, not to reason about a specific value.
> **Warning**: **A source narrowing selects membership, never provenance.** Filtering or probing by `datasource` keeps the contacts that source **contributed to**. It does not mean that source supplied the value you are looking at: the contact is a projection of everything that touched it, and any given field may have been won by a different source entirely. This is why [`/audience/coverage`](/platform-api/product-api/audience/probes) restates the meaning of a datasource narrowing inside its own response.
### What this means in practice
- **Counts of "contacts from source X" are counts of contacts that source helped build**, and the same person can be counted under two sources. The numbers are not a partition of the audience.
- **A value can change without your integration doing anything**, because another source with higher priority sent a different one.
- **Do not model identity yourself.** If you need two records to be the same person, give them the same `_user_id` (or a matching unique attribute) and let the merge happen. Writing your own dedupe on top produces a second, divergent identity model.
## The attribute catalogue
The attributes a contact can carry are **project-specific**: they depend on how the project is configured and what its data sources feed it. There is no static list to memorise, because yours is not the same as anyone else's. Read it live.
[`GET /project/{project}/specs/attributes` - Every attribute enabled on contacts in this project, with its type and flags.](/platform-api/product-api/reference)
```bash
curl "$BASE/specs/attributes" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Each entry carries the fields you need to decide whether you can filter on it, write to it, or show it:
- `uid` — `string`
The identifier you use as `key` in a filter condition, and as the field name when writing contacts. System attributes start with an underscore (`_user_id`, `_email`, `_phone_mobile`); custom ones never do.
- `label` — `string`
The human label, ready to display. Prefer it over `displayLabel`, which is the raw stored value and may still carry the panel's translation markers.
- `dataType` — `string`
What the attribute holds: `string`, `number`, `boolean`, `date` and friends. It decides which [operators](/platform-api/product-api/audience/query-filter#operators-reference) are valid on it, and whether a probe can list its values at all.
- `unique` — `boolean`
Whether the attribute takes part in merging. The full set is also returned as `metadata.uniqueAttributes`, which is the fastest way to learn a project's identity model in one call.
- `custom` — `boolean`
Whether it was created by the account rather than shipped by the platform.
- `readonly` — `boolean`
Whether it can be written. Computed and platform-owned attributes are read-only.
- `eventBased` — `boolean`
Whether the value is derived from the contact's events rather than supplied by a source. These update on their own as events arrive.
- `multivalue` — `integer`
Maximum number of values the attribute accepts. `1` means single-valued. On a multivalue attribute a contact can hold several values at once, which changes how you read any distribution over it.
- `mappeable` — `boolean`
Whether a data source can map an incoming field onto it.
- `enabled` — `boolean`
Whether the attribute is in use in this project. The endpoint returns enabled attributes.
### System, custom and internal
- **System attributes** are shipped by the platform and always begin with `_`. They cover identity (`_user_id`, `_email`, `_phone_mobile`), derived geography and language, subscription state, and platform bookkeeping.
- **Custom attributes** are created by the account for whatever the business needs. Their uid **cannot start with an underscore**, which is the rule that keeps the two namespaces from colliding.
- **Internal attributes** exist for the platform's own use and are not part of your working surface.
## Existing is not the same as usable
The catalogue tells you an attribute **exists**. It says nothing about whether it has data behind it. An attribute declared on the project and filled on 2% of contacts is present in the specs and absent in practice, and a filter built on it returns `200` with an empty result and no explanation.
That gap is what [`POST /audience/coverage`](/platform-api/product-api/audience/probes) exists to close: per attribute, how many contacts have it filled, how many distinct values it holds, and what those values actually are.
> **Tip**: Read the catalogue once per deploy to know the shape, and probe when you are about to build something on a specific attribute. The two answer different questions and neither substitutes for the other.
## Some keys are not attributes
A handful of keys accepted by `attribute_condition` are not project attributes at all: a universal search field, the sampling bucket, and other computed handles. They behave like attributes in a filter but never appear in the catalogue. They are documented with the grammar, in [Dynamic attributes](/platform-api/product-api/audience/query-filter#dynamic-attributes).
## What's next
- **[Events](/platform-api/product-api/audience/events)**: the other half of what a contact carries.
- **[Probes](/platform-api/product-api/audience/probes)**: which of these attributes actually hold data, and what values they hold.
- **[Query filter](/platform-api/product-api/audience/query-filter)**: how to write a condition on an attribute once you know it is usable.
- **[Counting and retrieval](/platform-api/product-api/audience/counting-and-retrieval)**: fetching a single contact by id, user id, phone or email.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/events
# Events
What an audience event is, how the project's event catalogue and its parameter schemas are discovered live, how events attach to a contact, and how to tell an event type that is declared apart from one that actually arrives.
**Language:** en
**Audience:** developer
**TLDR:** An event is an immutable record of something a contact did, attached to the audience contact so one person has one timeline. GET /project/{project}/specs/events lists the event types available in the project; GET /project/{project}/specs/events/{eventType} returns that type's parameters. Declared is not the same as received: POST /project/{project}/event/volumes says which types actually arrive, when the last one did, and what values their parameters carry.
**Search keywords:** event, events, audience event, event type, event types, event catalogue, event catalog, specs events, event parameters, parameter, which events does my project have, custom event, ecommerce event, order event, purchase event, event category, attribution, automation trigger, contact timeline, activity, behaviour, behavior, what did the contact do, declared but never received, event never arrives, event retention, event history
**Related pages:** /platform-api/product-api/audience/probes, /platform-api/product-api/audience/event-query-filter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/events/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/events.md (Markdown)
An event is an immutable record of something a person did: a purchase, a page view, a form submission, an appointment, anything the account decides to track. Events are the behavioural half of the audience. Attributes say what somebody **is**; events say what they **did**, and when.
Because contacts merge, so do timelines. Every event reported by any data source about a person ends up on that person's single audience contact, in one chronological stream, regardless of how many systems contributed to it.
## The event catalogue
Which event types a project can receive is project-specific, exactly like the attribute catalogue. Read it live rather than hardcoding a list.
[`GET /project/{project}/specs/events` - Every event type available in this project.](/platform-api/product-api/reference)
```bash
curl "$BASE/specs/events" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Each entry describes the type, not any instance of it:
- `uid` — `string`
The identifier used everywhere else: as the event type in a filter condition, and as the `event` argument of a probe. For example `ecommerce_order_create`, `ecommerce_product_view`, `appointment`.
- `name` — `string`
Human-readable name for the type.
- `category` — `string`
Which family the type belongs to (`ecommerce`, `crm`, `marketing`, `subscriptions`, `payments`, `meetings`, `contact_data`, `contact_behaviour`). Useful for grouping in a UI, not for filtering.
- `attribution` — `boolean`
Whether this type takes part in attribution tracking, which is what lets a conversion be credited to what preceded it.
- `automation` — `boolean`
Whether the type can trigger an automation. A type with `automation: false` can still be filtered on; it just will not start anything.
- `important` — `boolean`
Whether the platform treats the type as a headline event for the contact's timeline.
## Event parameters
An event carries **parameters**: the payload that makes one purchase different from another. They are declared per type, and their schema is discovered the same way.
[`GET /project/{project}/specs/events/{eventType}` - The parameter schema of one event type.](/platform-api/product-api/reference)
```bash
curl "$BASE/specs/events/ecommerce_product_purchase" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Each parameter reports its `parameter` key, `title`, `dataType`, whether it is `required`, its `multiValue` ceiling and, for strings, a `maxLength`. Parameter keys are namespaced by their event type when you filter on them: for a `create` event you filter on `create.source`, for an order on `ecommerce_order_create.order-euro-amount`. The [event query filter](/platform-api/product-api/audience/event-query-filter) page documents the resolution rules.
> **Note**: A parameter schema tells you what a type **can** carry. It does not tell you what any of those parameters actually hold in your project, and a filter on a value that never occurs is a valid filter with an empty result. For the real values, probe: see [what actually arrives](#declared-is-not-received) below.
## Reading a contact's events
Given an audience contact id, its timeline is one call.
[`GET /project/{project}/audience/{audienceId}/events` - The events attached to a single audience contact.](/platform-api/product-api/reference)
This needs `PROJECT_AUDIENCE_READ` **and** `PROJECT_AUDIENCE_DATA_EVENTS`, the second of which is gated by the subscription plan. Without it the call is refused rather than returning a thinner timeline.
To search events across the whole project rather than for one person, use the [event query filter](/platform-api/product-api/audience/event-query-filter) against `/event/search` and `/event/scroll`. Note that the event listing endpoints look back over a bounded window whose length depends on the subscription: read what the response reports rather than assuming your requested range was honoured.
## Declared is not received
The catalogue lists every type the project **could** receive. It cannot tell you which of them anything ever sends, and that difference is usually the one that decides whether a feature is worth building.
`POST /project/{project}/event/volumes` answers it: per declared type, the count inside a window, whether anything arrived, and when the last one did. A type that is declared and never received comes back as an explicit zero rather than being silently absent. Pass an `event` and the answer goes a level deeper, returning the values that type's parameters actually carry: product ids and names, categories, tags, vendors, the campaign ids and names behind an attribution, sources and mediums, statuses, methods.
[`POST /project/{project}/event/volumes` - Which event types arrive, how many, when the last one did, and what their parameters hold.](/platform-api/product-api/reference)
Four caveats travel with that answer and are easy to lose:
- A null `lastSeenAt` means nothing arrived **in that window**, never that the type was never seen.
- Where a parameter has both an id and a name, both come back. **Filter on the id** (`product-id`, `utm-id`), which is stable and unique, and **read the name** (`product-name`, `utm-campaign`), which is neither: a condition written against a name breaks the first time the customer renames the thing.
- The type counts are never sampled, but **the parameter values are**, above 10,000 contacts. Their answer is in `event.sampling`, a second block nested under `event`, not in the top-level `sampling` that reports on the counts. A value missing from a sampled list has not been shown not to occur.
- The probe reads the project's event history, while a **segment's** event condition is evaluated against a narrower, per-contact store. So a value the probe lists can match fewer people than its count suggests, sometimes none. Use the probe to pick a value, then count.
The full treatment, with the rest of the caveats, is on [Probes](/platform-api/product-api/audience/probes).
## Events in a contact filter
You do not need the event grammar to select **people** by what they did. The [audience query filter](/platform-api/product-api/audience/query-filter) has `event_condition` and `group_event` nodes for exactly that: "contacts who purchased in the last 30 days" is a contact-side filter with an event group inside it. Reach for the [event query filter](/platform-api/product-api/audience/event-query-filter) when the rows you want back are events, not people.
## What's next
- **[Probes](/platform-api/product-api/audience/probes)**: which types arrive and what their parameters really hold.
- **[Event query filter](/platform-api/product-api/audience/event-query-filter)**: the grammar for searching and aggregating events directly.
- **[Query filter](/platform-api/product-api/audience/query-filter#filter-by-events)**: selecting contacts by their events.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/query-filter
# Audience query filter
Search and segment the unified audience with a structured JSON filter. Combine attribute, event and segment conditions with AND/OR logic, count who can receive on a channel under a consent policy, paginate with cursors and run aggregations.
**Language:** en
**Audience:** developer
**TLDR:** A filter is a JSON object with a root group of attribute_condition, event_condition, group_event or segment_condition nodes joined by and/or, plus top-level scope keys applied on top of root: filterCompliance (consent and reach per channel: basic, no-opt-out, opt-in), filterCountryCodes, filterMandatoryAttributes, filterByChannelSupport. Posted to /project/{project}/audience/search and /audience/count; POST /project/{project}/segment saves the same filter as a named segment.
**Search keywords:** AQF, Audience Query Filter, Audience Contact Query Filter, audience filter, audience search, filterCompliance, compliance, consent, consent policy, opt-in, no-opt-out, no opt-out, opt-out, basic, subscribed, unsubscribed, suppression, marketing preference, count contacts, audience count, reach, reachable, who can receive, create segment, save segment, segment creation, named segment, new segment, queryFilter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/query-filter/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/query-filter.md (Markdown)
The **Audience Query Filter (AQF)** is a structured JSON filter posted to `/project/{project}/audience/search` (and a handful of related endpoints) to find contacts by attributes, tags, segment membership or associated events. It is the primary search surface for the unified audience and the building block behind every dynamic segment.
One grammar, three things to do with it: **search** it on `/audience/search`, **count** it on `/audience/count`, and **save** it on `/project/{project}/segment`, where the filter you just composed becomes a named segment the project keeps — one a campaign can target and another filter can reference through `in-segment`. See [Saving a filter as a segment](#saving-a-filter-as-a-segment).
> **Note**: The AQF is distinct from the generic [Query Filter](/platform-api/further-reading/query-filter) (QF) used on the Product API's list endpoints. Similar names, independent semantics — this page documents the audience-specific grammar.
> **Tip**: **Probe before you filter.** A condition on an attribute almost nobody filled, or on a value that does not occur in the data, is a perfectly valid filter that selects nobody: real uid, real operator, `200`, empty segment. [`POST /audience/coverage`](/platform-api/product-api/audience/probes) tells you which attributes this project actually fills in and what values they hold, so you write the condition against the data instead of against the schema. It accepts the same `root` documented on this page.
## Quickstart
### Contacts with email containing "yahoo"
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "contains",
"values": ["yahoo"]
}
]
},
"limit": 10,
"offset": 0,
"sortField": "_email",
"sortAsc": true,
"includeAllData": false
}
```
### Contacts with email containing ".com" AND specific tags
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "contains",
"values": [".com"]
},
{
"type": "attribute_condition",
"key": "_client_tags",
"operator": "matches-string",
"values": ["&&", "tag-5", "tag-csv-2"]
}
]
},
"limit": 100,
"offset": 0,
"sortField": "_email",
"sortAsc": false
}
```
## Basic concepts
### Filter structure
A query filter is an object with:
- **`root`** — the main filter condition, typically a `group` containing multiple conditions.
- **`limit`** — maximum number of results to return.
- **`offset`** — number of results to skip (for pagination).
- **`sortField`** — field to sort by (optional).
- **`sortAsc`** — sort direction: `true` for ascending, `false` for descending (optional).
- **`includeAllData`** — whether to include all contact data or just essential fields (optional, default: `true`).
### Condition types
- **`attribute_condition`** — filter contacts by a contact attribute.
- **`event_condition`** — filter by a field on an associated event.
- **`group_event`** — group event conditions that apply to the same event type.
- **`segment_condition`** — filter by segment membership.
- **`group`** — combine multiple conditions with AND/OR logic (`join: "and" | "or"`).
### Operators at a glance
| Family | Operators |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Generic | `match-all`, `match-none`, `exists`, `exists-not` |
| Boolean | `matches-bool` |
| String | `contains`, `contains-not`, `startswith`, `startswith-not`, `endswith`, `endswith-not`, `matches-string`, `matches-string-not` |
| Numeric | `matches-number`, `matches-number-not`, `range-number`, `range-number-not` |
| Date | `matches-date`, `range-date`, `range-date-not`, `range-date-relative`, `range-date-anniversary`, `range-date-dayversary`, `range-date-timeversary` |
| Geographic | `geopoint-distance` |
| Segment | `in-segment`, `in-segment-not` (only on `segment_condition`) |
See the [Operators reference](#operators-reference) at the bottom for the full schemas, `values` shapes and behavioural notes.
## Common patterns
### Filter by email
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "contains",
"values": [".com"]
}
]
},
"limit": 100,
"offset": 0,
"sortField": "_email",
"sortAsc": true
}
```
### Filter by tags
Tags use `matches-string` with a join keyword as the first element of `values`: `&&` (AND) or `||` (OR).
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_client_tags",
"operator": "matches-string",
"values": ["&&", "tag-5", "tag-csv-2"]
}
]
},
"limit": 100,
"offset": 0
}
```
### Filter by segment membership
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "segment_condition",
"key": "test-segment",
"operator": "in-segment"
},
{
"type": "segment_condition",
"key": "other-segment",
"operator": "in-segment-not"
}
]
},
"limit": 100,
"offset": 0
}
```
### Filter by events
Contacts with an email AND an `ecommerce_order_create` event that carries a `campaign` parameter:
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "exists",
"values": []
},
{
"type": "group_event",
"datasource": ["datasource-id-1", "datasource-id-2"],
"event": "ecommerce_order_create",
"children": [
{
"type": "event_condition",
"key": "campaign",
"operator": "exists",
"values": []
}
]
}
]
},
"limit": 10,
"offset": 0,
"sortField": "_email",
"sortAsc": true
}
```
### Date range queries
Contacts imported in the last 7 days:
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_date_imported",
"operator": "range-date-relative",
"values": {
"lowerOffset": -7,
"upperOffset": null,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day"
}
}
]
},
"limit": 100,
"offset": 0
}
```
### Complex event filtering
Contacts with `transactional_send` events (SMS) from the last 30 days **OR** with the `marketing` category:
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "exists",
"values": []
},
{
"type": "group_event",
"event": "transactional_send",
"join": "and",
"datasource": ["datasource-id-1", "datasource-id-2"],
"children": [
{
"type": "event_condition",
"key": "type",
"operator": "matches-string",
"values": ["sms"]
},
{
"type": "group",
"join": "or",
"children": [
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-relative",
"values": {
"lowerOffset": -30,
"lowerOffsetPeriod": "day",
"upperOffset": 0,
"upperOffsetPeriod": "day"
}
},
{
"type": "event_condition",
"key": "category",
"operator": "matches-string",
"values": ["marketing"]
}
]
}
]
}
]
},
"limit": 10
}
```
### Additional filter options
Top-level filters that refine the result set without going through `root`:
| Field | Effect |
| ----------------------- | ------------------------------------------------------------ |
| `filterAudienceIds` | Restrict to specific audience contact IDs. |
| `filterNotAudienceIds` | Exclude specific audience contact IDs. |
| `filterContactIds` | Restrict to specific datasource contact IDs. |
| `filterDatasourceIds` | Restrict to specific datasource IDs. |
| `filterUniversalSearch` | Search within the universal search field. |
| `filterSamplingPercent` | Retrieve only a percentage of results (0–100, default: 100). |
| `filterBucketMin` | Minimum bucket number (0–199, default: 0). |
| `filterBucketMax` | Maximum bucket number (0–199, default: 199). |
| `filterBucketIn` | Restrict to specific bucket numbers (array). |
| `filterBucketNotIn` | Exclude specific bucket numbers (array). |
Example combining universal search and sampling:
```json
{
"version": "0.0.1",
"limit": 100,
"offset": 0,
"sortField": "_email",
"sortAsc": true,
"filterUniversalSearch": ".com",
"filterSamplingPercent": 33,
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "contains",
"values": [".com"]
}
]
}
}
```
## Counting who can receive: `filterCompliance`
A plain count answers *how many contacts match these conditions*. It does not answer *how many of them can actually be messaged*, which is the figure that decides whether a campaign is worth preparing. `filterCompliance` turns the first question into the second: name a channel and a consent policy, and the filter applies the same consent and reach rules a campaign applies when it estimates its audience and again when it sends, so the count and the send cannot disagree.
It is a top-level key shaped as an object of channel to policy, applied on top of `root` rather than expressed as conditions inside it:
```json
{
"version": "0.0.1",
"filterCompliance": {
"sms": "no-opt-out"
}
}
```
Posted to `/project/{project}/audience/count`, that body returns how many contacts an SMS campaign under the `no-opt-out` policy reaches. Posted to `/project/{project}/audience/search`, it returns those contacts. Combine it with a `root` to scope the question to part of the audience:
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "segment_condition",
"key": "vip-customers",
"operator": "in-segment"
}
]
},
"filterCompliance": {
"sms": "opt-in"
},
"filterCountryCodes": ["ES", "PT"]
}
```
### The three policies
Consent lives in two attributes per channel: `_is_subscribed_`, which says whether the contact is still reachable on the channel at all, and `_accepts_marketing_`, which records their marketing preference. A policy is a rule over those two values.
| Policy | `_is_subscribed_` | `_accepts_marketing_` | Counts |
| ------------ | -------------------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
| `basic` | must not be `false` | ignored | every contact not blocked on the channel, whatever their marketing preference |
| `no-opt-out` | must not be `false` | must not be `false` | everyone except contacts who refused marketing; **no stated preference still counts** |
| `opt-in` | must not be `false` | must be `true` | only contacts with explicit marketing consent |
The middle row is the one that surprises callers: a contact imported without consent data has *no* stated preference, and `no-opt-out` (the common policy) reaches them. Only `opt-in` requires an explicit `true`. How a contact comes to hold each state, and what a STOP does to it, is the customer-facing side of the same model: [How consent works](/platform/en/consent/marketing-preference-vs-suppression) and [Consent policies](/platform/en/campaigns/compliance-policies) in the Platform guides.
> **Note**: **`no-opt-out` was previously called `opt-out`.** The API keeps accepting `opt-out` on input indefinitely, so existing integrations do not break, and normalizes it on the way in: every response returns `no-opt-out`. Treat `opt-out` as a legacy alias you may send and `no-opt-out` as the only value you will read back. The policy itself is unchanged — it is the name of a sending rule ("send unless the contact opted out"), not a contact state.
On top of consent, every policy requires a valid mobile number (`_phone_mobile`): a contact with no reachable number is not a recipient under any policy. For `rcs` and `whatsapp` the filter additionally drops contacts known **not** to support the channel, while keeping those whose support is still unknown, which is the same criterion a campaign with SMS fallback uses. And `rcs` reads the SMS consent attributes: a contact who opted out of SMS is opted out of RCS too.
### Channels, combinations and errors
- **Channels**: `sms`, `rcs`, `whatsapp`.
- **Policies**: `basic`, `no-opt-out`, `opt-in`. `opt-out` is still accepted as a legacy alias for `no-opt-out`.
- **Several channels are AND-ed.** `{"sms": "no-opt-out", "rcs": "opt-in"}` counts the contacts that satisfy both, not the union.
- **Omit the key** to get the raw count: no consent, no reach, just the conditions in `root`.
Channel and policy are matched exactly, with `opt-out` accepted as the one legacy alias. An unknown channel (`"SMS"`) or an unknown policy (`"optin"`) is rejected with a **`422 Unprocessable Entity`** carrying `type: "INVALID_COMPLIANCE_FILTER"` and the offending value, instead of being ignored: a typo that silently counted the whole audience would be worse than a failed request.
> **Tip**: With the same audience and the same policy, the count matches the audience figure the dashboard shows for that campaign, so an integration and a marketer looking at the panel see the same number. The one thing the count does not know is which countries the campaign's sender can actually reach, so treat sender coverage separately.
### Reading the project's policy
Every project carries a default consent policy per channel, and it is what a campaign draft created without an explicit `compliance` takes. Read it from the project resource:
```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
The policy is `generalConfig.channelSms.defaultCompliancePolicy` (and `channelRcs`, `channelWhatsapp`, with `channelDefaults` covering the channels that set none). It is always resolved when read, never `null`: the channel's own value, then `channelDefaults`, then `basic`. Feed that value straight into `filterCompliance` to count what a campaign under the project's own policy would reach, and see [Audience targeting](/platform-api/product-api/campaigns/audience#consent-policy) for how a draft resolves it.
### Top-level scope keys
`filterCompliance` belongs to a small family of keys that scope the whole query instead of describing a contact condition. They are applied on top of `root`, so consent, reach, country and origin never have to be expressed as conditions:
| Key | Type | Effect |
| --------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filterCompliance` | object | Consent and reach for a channel, under a named policy (above). |
| `filterMandatoryAttributes` | `string[]` | Attribute uids that must have a value, e.g. `["_phone_mobile"]` for contacts with a valid mobile. |
| `filterCountryCodes` | `string[]` | ISO 3166-1 alpha-2 codes. Keeps contacts whose resolved country matches, taking the phone's country first and `_country_code` after. |
| `filterByChannelSupport` | object | Reach by device support, per channel, for channels with verification-based reachability (`rcs`, `whatsapp`): `{"rcs": [true]}` keeps only verified contacts, `{"rcs": [true, null]}` also keeps those not verified yet. Already implied by `filterCompliance`; set it to be stricter. |
| `filterDatasourceIds` | `string[]` | Keeps contacts that came from any of these datasource ids. |
| `sortField` / `sortAsc` | `string` / `boolean` | Sorting, on `/audience/search` only. `/audience/count` ignores them, as it does `limit` and `offset`. |
The id, universal-search and bucket filters are in [Additional filter options](#additional-filter-options) above.
## Saving a filter as a segment
A filter you post to `/audience/search` or `/audience/count` lives for one request. Post it with a name to `/project/{project}/segment` instead and the project keeps it as a named segment: a campaign can target it, another filter can reference it through an `in-segment` condition, and you can ask for its current size whenever you need it.
The saved `queryFilter` takes exactly the grammar documented on this page and is validated the same way, so compose it against `/audience/count` first. The request shape, the scope it needs, the cached-size semantics and the usage audit all live on the **[Segments](/platform-api/product-api/audience/segments)** page.
[`POST /project/{project}/segment` - Save an audience filter as a named segment the project keeps.](/platform-api/product-api/audience/segments)
> **Note**: Consent and channel reach do not belong inside a saved segment. They are decided when something is sent, under the project's policy for that channel, so a segment stores who the contacts **are** and not who may be messaged. Keep `filterCompliance` for the count and leave it out of the `queryFilter` you save.
## Dynamic attributes
These are not project attributes but can be used as `key` in `attribute_condition`:
- `_universal_search` — universal search field across common identifying attributes.
- `_bucket` — bucket number (0–199), for deterministic sampling.
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_universal_search",
"operator": "contains",
"values": ["brittney"]
},
{
"type": "attribute_condition",
"key": "_bucket",
"operator": "range-number",
"values": {
"lowerNumber": 0,
"upperNumber": 40
}
}
]
},
"limit": 100,
"offset": 0,
"sortField": "_email",
"sortAsc": true
}
```
### Sampling with buckets
Bucket ranges are deterministic — the same contact always falls into the same bucket — so they are ideal for consistent samples across runs.
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_universal_search",
"operator": "contains",
"values": ["brittney"]
},
{
"type": "attribute_condition",
"key": "_bucket",
"operator": "range-number",
"values": {
"lowerNumber": 0,
"upperNumber": 10
}
}
]
},
"limit": 100,
"offset": 0,
"sortField": "_email",
"sortAsc": true
}
```
## Cursor-based pagination
For large traverses, use cursor pagination instead of `offset`/`limit`. A cursor keeps a consistent snapshot of the data across requests.
### How it works
1. **First request** — run a query without a cursor. The response includes a `cursor` string.
2. **Subsequent requests** — pass the `cursor` from the previous response to continue.
3. **End of results** — when `cursor` is `null`, there are no more results.
### Cursor format
The cursor is a **base64-encoded string** containing a snapshot identifier, position information and a keep-alive setting. You do not need to parse or modify it — just pass it back as-is.
### Using cursors
```json
// First request
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "exists",
"values": []
}
]
},
"limit": 100
}
// Response includes: { "cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0=", ... }
// Next request — just pass the cursor
{
"cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0="
}
```
> **Note**: - The cursor expires after a period of inactivity (default: 1–5 minutes).
> - `offset` is **not compatible** with cursor pagination and is cleared when a cursor is set.
> - Cursors are **stateless** — any process holding the cursor can continue the iteration.
## Aggregations
> **Warning**: Aggregations are **disabled by default** and are only available on endpoints that explicitly support them. Check the endpoint in the [API Reference](/platform-api/product-api/reference) before using them. Access requires the `PROJECT_AGGREGATIONS` scope, which is manually granted by Instasent and reserved for trusted partners.
Aggregations analyse and summarise the filtered contacts. They use the standard format with configuration inside `params`; the `@` prefix references an attribute and is auto-resolved.
### Terms aggregation
Group contacts by unique values:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"mi-agregacion": {
"type": "terms",
"params": {
"field": "@_country_code",
"size": 3
},
"aggs": {
"mi-otra-agregacion": {
"type": "terms",
"params": {
"field": "@_email"
}
}
}
}
}
}
```
### Terms with "other" bucket
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"topCountries": {
"type": "terms",
"params": {
"field": "@_country_code",
"size": 5,
"other_bucket": true,
"other_bucket_label": "other"
}
}
}
}
```
Returns the top 5 countries plus an "other" bucket containing the count of everything else.
### Nested terms with "other" buckets
Enable "other" buckets at multiple levels:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"mainCategories": {
"type": "terms",
"params": {
"field": "@_product_category",
"size": 3,
"other_bucket": true,
"other_bucket_label": "other_categories"
},
"aggs": {
"subCategories": {
"type": "terms",
"params": {
"field": "@_product_subcategory",
"size": 2,
"other_bucket": true,
"other_bucket_label": "other_subcategories"
}
}
}
}
}
}
```
### Range aggregations
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"contactsByBucketRange": {
"type": "range",
"params": {
"field": "@_bucket",
"ranges": [
{ "from": 0, "to": 33 },
{ "from": 33, "to": 66 },
{ "from": 66, "to": 100 }
]
}
}
}
}
```
### Getting tags
List all system and client tags:
```json
// POST /project/my-project-1/audience/search
{
"version": "0.0.1",
"limit": 0,
"aggregations": {
"system_tags": {
"type": "terms",
"params": { "field": "@_tags", "size": 100 }
},
"client_tags": {
"type": "terms",
"params": { "field": "@_client_tags", "size": 100 }
}
}
}
```
### Nested aggregations
Aggregate on nested fields (like events):
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"sortField": "_email",
"sortAsc": true,
"aggregations": {
"mi-agregacion": {
"type": "nested",
"params": { "path": "events" },
"aggs": {
"tipo-de-eventos": {
"type": "terms",
"params": { "field": "events.type" }
}
}
}
}
}
```
## Native event attributes
When filtering by events, these native event attributes are available:
| Attribute | Meaning |
| ------------------------ | ------------------------------ |
| `received-at` | When the event was received. |
| `created-at` | When the event was created. |
| `event-source` | Source of the event. |
| `event-type` | Type of event. |
| `audience-id` | ID of the audience contact. |
| `audience-ids` | Multiple audience contact IDs. |
| `audience-ds-ids` | Audience datasource IDs. |
| `audience-categories` | Audience categories. |
| `audience-target-groups` | Audience target groups. |
| `ds-contact-id` | Datasource contact ID. |
| `ds-id` | Datasource ID. |
| `ds-event-id` | Datasource event ID. |
## Operators reference
Every condition node has the shape:
```json
{
"type": "attribute_condition",
"key": "",
"operator": "",
"values": ""
}
```
The subsections below describe, for each operator, the exact shape of `values` and any behavioural notes. About `key`:
- Native attributes have UIDs prefixed with `_` (`_email`, `_phone_mobile`, `_country_code`, `_client_tags`, `_is_subscribed_sms`, `_date_birthday`, `_date_imported`, `_bucket`, `_geopoint`, …). They are defined by Instasent and always available.
- Custom attributes use the UID configured by the project (no leading `_`).
- On `event_condition`, `key` is an event attribute — either a [native event attribute](#native-event-attributes) or a custom event parameter defined by the project.
- On `segment_condition`, `key` is the segment identifier (slug), not an attribute UID.
Conventions used below:
- **Negation.** Every operator with a `-not` suffix takes the same `values` as its positive counterpart and inverts the match. A missing attribute is *not matched* by the positive operator and *is matched* by the negated one — i.e. negation is "not (positive match)", which includes contacts where the attribute is absent.
- **Multi-value attributes + `&&` prefix.** For array-valued attributes (e.g. `_client_tags`), string and numeric "list" operators default to OR semantics across the provided values. Passing `"&&"` as the first element of `values` switches to AND — *all* values must be present. `"||"` (the default) is also accepted explicitly.
- **Condition type.** Most operators work on both `attribute_condition` and `event_condition`. Exceptions: `in-segment` / `in-segment-not` are valid only on `segment_condition`, and `range-date-dayversary` is valid only on `event_condition` against `created-at`.
### Generic
These work regardless of the attribute's data type.
#### `match-all`
Matches every contact. Used internally; children of a group with this operator are collapsed away. `values` is ignored.
#### `match-none`
Matches no contacts. Used internally for invalid or empty queries. `values` is ignored.
#### `exists`
The attribute is present on the contact (non-null, and non-empty for array attributes).
```json
{ "type": "attribute_condition", "key": "_email", "operator": "exists", "values": [] }
```
#### `exists-not`
The attribute is missing or null. Same `values` shape as `exists`.
### Boolean
#### `matches-bool`
Exact boolean match. `values` must be an array with exactly one element: `[true]` or `[false]`.
```json
{ "type": "attribute_condition", "key": "_is_subscribed_sms", "operator": "matches-bool", "values": [true] }
```
> **Note**: There is no `matches-bool-not`. Use `matches-bool` with the opposite boolean, or wrap an `exists-not` inside a negated group.
### String
Apply to attribute data types `STRING`, `KEYWORD` and `TEXT`. All string operators accept an array of strings; the first element may optionally be `"&&"` or `"||"` to set the combination mode for multi-value attributes (default `||` / OR).
| Operator | Behaviour | Case | Min length |
| -------------------- | --------------------------- | ---------------- | ---------- |
| `contains` | Substring match (`*value*`) | Case-insensitive | 2 chars |
| `contains-not` | Inverse of `contains` | Case-insensitive | 2 chars |
| `startswith` | Prefix match (`value*`) | Case-insensitive | 1 char |
| `startswith-not` | Inverse of `startswith` | Case-insensitive | 1 char |
| `endswith` | Suffix match (`*value`) | Case-insensitive | 1 char |
| `endswith-not` | Inverse of `endswith` | Case-insensitive | 1 char |
| `matches-string` | Exact value match | Case-sensitive | 1 char |
| `matches-string-not` | Inverse of `matches-string` | Case-sensitive | 1 char |
Max value length for `contains` / `contains-not`: 128 chars.
OR across multiple values (default):
```json
{ "type": "attribute_condition", "key": "_email", "operator": "contains", "values": ["yahoo", "hotmail"] }
```
AND across multiple values (for multi-value attributes such as `_client_tags`):
```json
{
"type": "attribute_condition",
"key": "_client_tags",
"operator": "matches-string",
"values": ["&&", "tag-5", "tag-csv-2"]
}
```
The example above matches contacts that carry **both** `tag-5` and `tag-csv-2`. Without `"&&"` it would match contacts that carry either of them.
### Numeric
Apply to data types `INT` and `DECIMAL`.
#### `matches-number` / `matches-number-not`
Exact match against one or more numbers. Same `&&` / `||` semantics as the string operators.
```json
{ "type": "attribute_condition", "key": "_bucket", "operator": "matches-number", "values": [10, 25, 50] }
```
#### `range-number` / `range-number-not`
Range query. `values` is an **object**, not an array:
```json
{
"type": "attribute_condition",
"key": "_bucket",
"operator": "range-number",
"values": {
"lowerNumber": 0,
"upperNumber": 40,
"lowerExcludeEquals": false,
"upperExcludeEquals": false
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | -------------- | ------- | ------------------------------------------------------------------------ |
| `lowerNumber` | number \| null | `null` | Lower bound. `null` = unbounded below. |
| `upperNumber` | number \| null | `null` | Upper bound. `null` = unbounded above. |
| `lowerExcludeEquals` | bool | `false` | If `true`, lower bound is exclusive (`gt`); otherwise inclusive (`gte`). |
| `upperExcludeEquals` | bool | `false` | If `true`, upper bound is exclusive (`lt`); otherwise inclusive (`lte`). |
Both bounds `null` on `range-number` matches everything (and nothing on `range-number-not`).
### Date
Apply to data type `DATE`. All date operators honour the project timezone.
#### `matches-date`
Exact day match. Internally expanded to `[00:00:00, 23:59:59]` in the project timezone.
```json
{
"type": "attribute_condition",
"key": "_date_birthday",
"operator": "matches-date",
"values": { "date": "1990-05-14" }
}
```
| Field | Type | Notes |
| ------ | --------------------- | ---------------------- |
| `date` | string (`YYYY-MM-DD`) | Required. Exactly one. |
#### `range-date` / `range-date-not`
Absolute date range.
```json
{
"type": "attribute_condition",
"key": "_date_imported",
"operator": "range-date",
"values": {
"lowerDate": "2026-01-01",
"upperDate": "2026-03-31",
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": true,
"upperRounding": true
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | ------------------------- | ------- | ----------------------------------------------------------------- |
| `lowerDate` | ISO date/datetime \| null | `null` | Lower bound. `null` = unbounded below. |
| `upperDate` | ISO date/datetime \| null | `null` | Upper bound. `null` = unbounded above. |
| `lowerExcludeEquals` | bool | `false` | Exclude equality on lower bound (`gt` vs `gte`). |
| `upperExcludeEquals` | bool | `false` | Exclude equality on upper bound (`lt` vs `lte`). |
| `lowerRounding` | bool | `false` | If `true`, rounds the lower bound to the start of its day (`/d`). |
| `upperRounding` | bool | `false` | If `true`, rounds the upper bound to the end of its day (`/d`). |
#### `range-date-relative` / `range-date-relative-not`
Range expressed relative to *now*. Useful for "last N days", "next month", etc.
```json
{
"type": "attribute_condition",
"key": "_date_imported",
"operator": "range-date-relative",
"values": {
"lowerOffset": -7,
"upperOffset": 0,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day",
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": false,
"upperRounding": false
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | ----------- | ------- | ------------------------------------------------------------------------------- |
| `lowerOffset` | int \| null | `null` | Signed offset from now. Negative = past, positive = future. `null` = unbounded. |
| `upperOffset` | int \| null | `null` | Signed offset from now. |
| `lowerOffsetPeriod` | enum | `"day"` | Unit of `lowerOffset`. One of: `min`, `hour`, `day`, `week`, `month`, `year`. |
| `upperOffsetPeriod` | enum | `"day"` | Unit of `upperOffset`. Same options. |
| `lowerExcludeEquals` | bool | `false` | Same semantics as `range-date`. |
| `upperExcludeEquals` | bool | `false` | Same semantics as `range-date`. |
| `lowerRounding` | bool | `false` | Rounds the bound to the start of the period. |
| `upperRounding` | bool | `false` | Rounds the bound to the end of the period. |
Example — "imported in the last 30 days":
```json
{
"lowerOffset": -30, "lowerOffsetPeriod": "day",
"upperOffset": 0, "upperOffsetPeriod": "day"
}
```
#### `range-date-anniversary`
Matches date anniversaries independent of year (e.g. "birthday is in the next 7 days", "anniversary falls between Jan 25 and Dec 01"). Only available on DATE attributes whose field supports the anniversary index.
```json
// Relative mode — N days before/after today's anniversary
{
"values": {
"mode": "relative",
"lowerOffsetDays": 0,
"upperOffsetDays": 7
}
}
// Absolute mode — month-day range (format MMDD)
{
"values": {
"mode": "absolute",
"lowerDate": "0125",
"upperDate": "1201"
}
}
```
| Field | Type | Required when | Notes |
| ----------------- | ---------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `mode` | `"relative"` \| `"absolute"` | always | Default `"relative"`. |
| `lowerOffsetDays` | int | `mode = relative` | Signed. Negative = before, positive = after. `gte`. |
| `upperOffsetDays` | int | `mode = relative` | Signed. Exclusive upper (`lt`). Default `1`. |
| `lowerDate` | string (`MMDD`) | `mode = absolute` | e.g. `"0125"` = Jan 25. |
| `upperDate` | string (`MMDD`) | `mode = absolute` | e.g. `"1201"` = Dec 01. Inverted ranges (e.g. `1215` → `0115`) wrap automatically across the year boundary. |
#### `range-date-dayversary`
Matches a weekday + hour-of-day window. Only valid on `event_condition` against `created-at`. Use it for recurring weekly schedules — e.g. "events on Friday evenings".
```json
{
"values": { "lowerDay": "105", "upperDay": "523" }
}
```
| Field | Type | Format | Example |
| ---------- | ------ | -------------------------------------------------------------------------------- | ------------------------------ |
| `lowerDay` | string | `DHH` — 1 digit weekday (`1` = Monday … `7` = Sunday) + 2 digit hour (`00`–`23`) | `"105"` = Monday 05:00, `gte`. |
| `upperDay` | string | same | `"523"` = Friday 23:00, `lte`. |
If the lower day/hour is *greater* than the upper (e.g. Friday → Monday), the range wraps across the week boundary automatically.
#### `range-date-timeversary`
Matches a time-of-day window independent of the date. Two `mode`s, analogous to `range-date-anniversary`.
```json
// Relative mode — N minutes around the current time
{
"values": {
"mode": "relative",
"lowerOffsetMinutes": -15,
"upperOffsetMinutes": 15
}
}
// Absolute mode — HH:MM range (format HHMM or HH:MM — colons are stripped)
{
"values": {
"mode": "absolute",
"lowerTime": "0550",
"upperTime": "2200"
}
}
```
| Field | Type | Required when | Notes |
| -------------------- | ---------------------------- | ----------------- | ------------------------------------------------------- |
| `mode` | `"relative"` \| `"absolute"` | always | Default `"relative"`. |
| `lowerOffsetMinutes` | int | `mode = relative` | Signed, `gte`. |
| `upperOffsetMinutes` | int | `mode = relative` | Signed, `lt`. Default `1`. |
| `lowerTime` | string (`HHMM`) | `mode = absolute` | 24h. `"0550"` = 05:50. |
| `upperTime` | string (`HHMM`) | `mode = absolute` | `"2200"` = 22:00. Inverted ranges wrap across midnight. |
### Geographic
#### `geopoint-distance`
Matches contacts within a radius of a `(longitude, latitude)` point. Only applies to geopoint attributes.
```json
{
"type": "attribute_condition",
"key": "_geopoint",
"operator": "geopoint-distance",
"values": {
"longitude": -3.7038,
"latitude": 40.4168,
"distance": 25
}
}
```
| Field | Type | Required | Notes |
| ----------- | ------- | -------- | ----------------------------------- |
| `longitude` | decimal | yes | WGS-84 longitude. |
| `latitude` | decimal | yes | WGS-84 latitude. |
| `distance` | decimal | yes | Radius. Default unit is kilometres. |
All three fields are required; missing any raises a validation error.
### Segment
Used only on `segment_condition` nodes. The `key` is the segment identifier (slug), and `values` is ignored.
#### `in-segment`
Contact belongs to the named segment. Segments are evaluated recursively (a segment may reference other segments) with a nesting depth cap of 4 and circular-reference detection. A missing segment resolves to `match-none` — except the special `_all` segment, which resolves to `match-all`.
```json
{ "type": "segment_condition", "key": "high-value-customers", "operator": "in-segment" }
```
#### `in-segment-not`
Contact does *not* belong to the named segment. Same `key` semantics.
### `values` shape — quick reference
| Operator | `values` shape | | |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | - | --- |
| `match-all`, `match-none` | ignored | | |
| `exists`, `exists-not` | `[]` | | |
| `matches-bool` | `[true]` or `[false]` | | |
| `contains(-not)`, `startswith(-not)`, `endswith(-not)`, `matches-string(-not)` | `string[]`, optional leading `"&&"` / \`" | | "\` |
| `matches-number(-not)` | `number[]`, optional leading `"&&"` / \`" | | "\` |
| `range-number(-not)` | `{ lowerNumber, upperNumber, lowerExcludeEquals?, upperExcludeEquals? }` | | |
| `matches-date` | `{ date: "YYYY-MM-DD" }` | | |
| `range-date(-not)` | `{ lowerDate, upperDate, lowerExcludeEquals?, upperExcludeEquals?, lowerRounding?, upperRounding? }` | | |
| `range-date-relative(-not)` | `{ lowerOffset, upperOffset, lowerOffsetPeriod, upperOffsetPeriod, lower/upperExcludeEquals?, lower/upperRounding? }` | | |
| `range-date-anniversary` | `{ mode: "relative", lowerOffsetDays, upperOffsetDays }` or `{ mode: "absolute", lowerDate: "MMDD", upperDate: "MMDD" }` | | |
| `range-date-dayversary` | `{ lowerDay: "DHH", upperDay: "DHH" }` (events only) | | |
| `range-date-timeversary` | `{ mode: "relative", lowerOffsetMinutes, upperOffsetMinutes }` or `{ mode: "absolute", lowerTime: "HHMM", upperTime: "HHMM" }` | | |
| `geopoint-distance` | `{ longitude, latitude, distance }` | | |
| `in-segment`, `in-segment-not` | ignored (segment id goes in `key`) | | |
## What's next
- **[Probes](/platform-api/product-api/audience/probes)**: measure what the audience actually contains before you filter it: attribute fill rates, the values an attribute really holds, and which event types arrive.
- **[Counting and retrieval](/platform-api/product-api/audience/counting-and-retrieval)**: which endpoint to post this filter to, and what each of them returns.
- **[Segments](/platform-api/product-api/audience/segments)**: keep the filter you just composed as a named segment the project holds, for campaigns and for other filters to reference.
- **[Audience event query filter](/platform-api/product-api/audience/event-query-filter)** (EQF): the separate grammar for searching events directly.
- **[Query Filter](/platform-api/further-reading/query-filter)** (QF): the generic filter for the Product API's list endpoints, unrelated to this one.
- **[API Reference](/platform-api/product-api/reference)**: endpoints that accept this filter (`/audience/search`, `/audience/count`, `/audience/scroll`, `/audience/segment/{uid}/scroll`, `/audience/aggregations`, `/audience/coverage` to probe the population it selects, and `/segment` to save one).
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/event-query-filter
# Audience event query filter
Search and aggregate audience events with a structured JSON filter. Group by event type, filter on parameters, run date histograms and sum metrics across the last N days.
**Language:** en
**Audience:** developer
**Search keywords:** EQF, Audience Event Query Filter, Event Query Filter, event filter, event search
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/event-query-filter/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/event-query-filter.md (Markdown)
The **Audience Event Query Filter (EQF)** is a structured JSON filter posted to `/project/{project}/event/search` (and related event endpoints) to find events by type, parameters, creation date or any native event attribute. It shares the overall shape of the [Audience query filter](/platform-api/product-api/audience/query-filter) (AQF) but is specific to the event collection — the condition grammar is narrower and the aggregation surface is richer (date histograms, sums, metrics).
> **Note**: Event parameters live under the event type namespace. For a `create` event you filter on `create.source`; for an order you filter on `ecommerce_order_create.order-euro-amount`. Parameter keys are auto-resolved against the event type configuration of your project.
> **Tip**: **Probe before you filter.** A filter on an event type that never arrives, or on a parameter value spelled differently in the data, returns `200` and nothing. [`POST /event/volumes`](/platform-api/product-api/audience/probes) reports which types this project actually receives inside a window, when the last one arrived, and, for one type, the real values its parameters hold. It accepts the same `root` documented on this page.
## Quickstart
### All `create` events from the last 30 days
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["create"]
},
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-relative",
"values": {
"lowerOffset": -30,
"upperOffset": null,
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": false,
"upperRounding": false,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day"
}
}
]
},
"limit": 10,
"offset": 0
}
```
### Ecommerce orders between €100 and €200
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["ecommerce_order_create"]
},
{
"type": "event_condition",
"key": "ecommerce_order_create.order-euro-amount",
"operator": "range-number",
"values": {
"lowerNumber": 100.0,
"upperNumber": 200.0
}
}
]
},
"limit": 10,
"offset": 0
}
```
## Basic concepts
### Filter structure
- **`root`** — the main filter condition, typically a `group` of event conditions.
- **`limit`** — maximum number of results to return.
- **`offset`** — number of results to skip.
- **`sortField`** — field to sort by (optional).
- **`sortAsc`** — sort direction: `true` for ascending, `false` for descending (optional).
### Condition types
- **`event_condition`** — filter by a native event attribute or an event parameter.
- **`group`** — combine multiple conditions with AND/OR logic (`join: "and" | "or"`).
### Native event attributes
Available directly as `key` in an `event_condition`:
| Attribute | Meaning |
| ------------------------ | ------------------------------------------------------------------ |
| `bucket` | Bucket number. |
| `received-at` | When the event was received. |
| `created-at` | When the event was created. |
| `event-source` | Source of the event. |
| `event-type` | Type of event (e.g. `create`, `update`, `ecommerce_order_create`). |
| `audience-id` | ID of the audience contact. |
| `audience-ids` | Multiple audience contact IDs. |
| `audience-ds-ids` | Audience datasource IDs. |
| `audience-categories` | Audience categories. |
| `audience-target-groups` | Audience target groups. |
| `ds-contact-id` | Datasource contact ID. |
| `ds-id` | Datasource ID. |
| `ds-event-id` | Datasource event ID. |
### Event parameters
Filter by event-specific parameters by prefixing the key with the event type. Examples:
- `create.source` — the `source` parameter of `create` events.
- `ecommerce_order_create.order-euro-amount` — the `order-euro-amount` parameter of `ecommerce_order_create` events.
Parameter keys are auto-resolved based on the event type configuration of the project.
## Common patterns
### By event type and source
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["create"]
},
{
"type": "event_condition",
"key": "create.source",
"operator": "matches-string",
"values": ["datasource|_instasent"]
}
]
},
"limit": 10,
"offset": 0,
"sortField": "create.source",
"sortAsc": true
}
```
### Date range
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-relative",
"values": {
"lowerOffset": -30,
"upperOffset": null,
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": false,
"upperRounding": false,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day"
}
}
]
},
"limit": 10,
"offset": 0
}
```
### Events for specific contacts
```json
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "event_condition",
"key": "audience-id",
"operator": "matches-string",
"values": ["0aCcUIewHALKk9jpaS9QaazifbmUr8fB"]
}
]
},
"limit": 100,
"offset": 0
}
```
### Additional filter options
Top-level filters that narrow the result set without going through `root`:
| Field | Effect |
| ----------------------------- | ------------------------------------------------------------ |
| `filterAudienceIds` | Restrict to specific audience contact IDs. |
| `filterEventIds` | Restrict to specific event IDs. |
| `filterEventIdsNot` | Exclude specific event IDs. |
| `filterDatasourceIds` | Restrict to specific datasource IDs. |
| `filterAudienceDatasourceIds` | Restrict events for contacts from specific datasources. |
| `filterSamplingPercent` | Retrieve only a percentage of results (0–100, default: 100). |
| `filterBucketMin` | Minimum bucket number (0–100, default: 0). |
| `filterBucketMax` | Maximum bucket number (0–100, default: 100). |
Example:
```json
{
"version": "0.0.1",
"limit": 100,
"offset": 0,
"root": {
"type": "group",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["create"]
}
]
},
"filterSamplingPercent": 33,
"filterAudienceIds": ["0aCcUIewHALKk9jpaS9QaazifbmUr8fB"],
"filterEventIdsNot": ["S9QaazifbmUr8fB0aCcUIewHALKk9jpa"]
}
```
## Cursor-based pagination
For large traverses, use cursor pagination instead of `offset`/`limit`. A cursor keeps a consistent snapshot of the data across requests.
### How it works
1. **First request** — run a query without a cursor. The response includes a `cursor` string.
2. **Subsequent requests** — pass the `cursor` from the previous response to continue.
3. **End of results** — when `cursor` is `null`, there are no more results.
### Cursor format
The cursor is a **base64-encoded string** containing a snapshot identifier, position information and a keep-alive setting. You do not need to parse or modify it — just pass it back as-is.
### Using cursors
```json
// First request
{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["create"]
}
]
},
"limit": 100
}
// Response includes: { "cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0=", ... }
// Next request — just pass the cursor
{
"cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0="
}
```
> **Note**: - The cursor expires after a period of inactivity (default: 1–5 minutes).
> - `offset` is **not compatible** with cursor pagination and is cleared when a cursor is set.
> - Cursors are **stateless** — any process holding the cursor can continue the iteration.
## Aggregations
> **Warning**: Aggregations are **disabled by default** and are only available on endpoints that explicitly support them. Check the endpoint in the [API Reference](/platform-api/product-api/reference) before using them. Access requires the `PROJECT_AGGREGATIONS` scope, which is manually granted by Instasent and reserved for trusted partners.
Aggregations analyse and summarise the filtered events. All configuration goes inside `params`; use the `@` prefix to reference event parameters — they are auto-resolved.
### Date histogram
Group events by time intervals:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"eventsByDay": {
"type": "date_histogram",
"params": {
"field": "@created-at",
"calendar_interval": "1d",
"format": "yyyy-MM-dd",
"time_zone": "UTC"
}
}
}
}
```
### Date histogram with nested aggregations
Calculate metrics for each time period:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"salesByDate": {
"type": "date_histogram",
"params": {
"field": "@ecommerce_order_create.created-at",
"calendar_interval": "1d",
"format": "yyyy-MM-dd (EEE)",
"time_zone": "Europe/Madrid"
},
"aggs": {
"totalSales": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.order-euro-amount" }
},
"orderCount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-count" }
}
}
}
}
}
```
### Fixed interval vs calendar interval
- **`calendar_interval`** — calendar-aware intervals (`1d`, `1w`, `1M`, `1y`).
- **`fixed_interval`** — fixed time intervals (`1h`, `6h`, `30m`).
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"salesBy6HourInterval": {
"type": "date_histogram",
"params": {
"field": "@ecommerce_order_create.created-at",
"fixed_interval": "6h",
"format": "HH:00",
"time_zone": "UTC"
},
"aggs": {
"salesAmount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.order-euro-amount" }
}
}
}
}
}
```
### Terms aggregation
Group events by unique values in a field:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"eventTypes": {
"type": "terms",
"params": {
"field": "@event-type",
"size": 10
}
}
}
}
```
### Terms with nested aggregations
Detailed metrics per category:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"topProducts": {
"type": "terms",
"params": {
"field": "@ecommerce_order_create.product-name",
"size": 5,
"missing": "unknown",
"order": { "salesCount": "desc" }
},
"aggs": {
"salesCount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-count" }
},
"salesEuroAmount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-price-euro" }
}
}
}
}
}
```
### Sum aggregation
Calculate totals across matching events:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"totalRevenue": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.order-euro-amount" }
}
}
}
```
### Complete sales analysis
Sales patterns across multiple dimensions:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"root": {
"type": "group",
"children": [
{
"type": "event_condition",
"key": "event-type",
"operator": "matches-string",
"values": ["ecommerce_order_create"]
},
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-relative",
"values": {
"lowerOffset": -21,
"upperOffset": 0,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day"
}
}
]
},
"aggregations": {
"totalSalesAmount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.order-euro-amount" }
},
"salesByDate": {
"type": "date_histogram",
"params": {
"field": "@ecommerce_order_create.created-at",
"calendar_interval": "1d",
"format": "yyyy-MM-dd (EEE)",
"time_zone": "UTC"
},
"aggs": {
"salesCount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-count" }
},
"salesEuroAmount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.order-euro-amount" }
}
}
},
"topProducts": {
"type": "terms",
"params": {
"field": "@ecommerce_order_create.product-name",
"size": 10,
"missing": "unknown",
"order": { "salesCount": "desc" }
},
"aggs": {
"salesCount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-count" }
},
"salesEuroAmount": {
"type": "sum",
"params": { "field": "@ecommerce_order_create.product-price-euro" }
}
}
}
}
}
```
### Listing event types in a project
```json
{
"version": "0.0.1",
"limit": 0,
"aggregations": {
"total": {
"type": "terms",
"params": {
"field": "@event-type",
"size": 100
}
}
}
}
```
### Range aggregations
Group events into ranges:
```json
{
"version": "0.0.1",
"limit": 0,
"offset": 0,
"aggregations": {
"contactsByBucketRange": {
"type": "range",
"params": {
"field": "@bucket",
"ranges": [
{ "from": 0, "to": 33 },
{ "from": 33, "to": 66 },
{ "from": 66, "to": 100 }
]
}
}
}
}
```
## Operators reference
Every condition node has the shape:
```json
{
"type": "event_condition",
"key": "",
"operator": "",
"values": ""
}
```
The subsections below describe, for each operator, the exact shape of `values` and any behavioural notes. About `key`:
- A [native event attribute](#native-event-attributes) (e.g. `event-type`, `created-at`, `received-at`, `audience-id`, `ds-id`).
- An event parameter, scoped by event type: `.` — e.g. `ecommerce_order_create.order-euro-amount`, `create.source`. Parameters are resolved against the event-type configuration of the project, and their data type drives which operators are valid.
| Family | Operators |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Generic | `match-all`, `match-none`, `exists`, `exists-not` |
| Boolean | `matches-bool` |
| String | `contains`, `contains-not`, `startswith`, `startswith-not`, `endswith`, `endswith-not`, `matches-string`, `matches-string-not` |
| Numeric | `matches-number`, `matches-number-not`, `range-number`, `range-number-not` |
| Date | `matches-date`, `range-date`, `range-date-not`, `range-date-relative`, `range-date-anniversary`, `range-date-dayversary`, `range-date-timeversary` |
| Geographic | `geopoint-distance` |
Conventions used below:
- **Negation.** Every operator with a `-not` suffix takes the same `values` as its positive counterpart and inverts the match. A missing field is *not matched* by the positive operator and *is matched* by the negated one — i.e. negation is "not (positive match)", which includes events where the field is absent.
- **Multi-value fields + `&&` prefix.** For array-valued fields, string and numeric "list" operators default to OR semantics across the provided values. Passing `"&&"` as the first element of `values` switches to AND — *all* values must be present. `"||"` (the default) is also accepted explicitly.
### Generic
These work regardless of the field's data type.
#### `match-all`
Matches every event. Used internally. `values` is ignored.
#### `match-none`
Matches no events. Used internally. `values` is ignored.
#### `exists`
The field is present on the event (non-null, and non-empty for array fields).
```json
{ "type": "event_condition", "key": "ecommerce_order_create.coupon-code", "operator": "exists", "values": [] }
```
#### `exists-not`
The field is missing or null.
### Boolean
#### `matches-bool`
Exact boolean match. `values` must be an array with exactly one element: `[true]` or `[false]`.
```json
{ "type": "event_condition", "key": "ecommerce_order_create.is-first-order", "operator": "matches-bool", "values": [true] }
```
### String
Apply to fields of type `STRING`, `KEYWORD` and `TEXT`. All string operators accept an array of strings; the first element may optionally be `"&&"` or `"||"` to set the combination mode for multi-value fields (default `||` / OR).
| Operator | Behaviour | Case | Min length |
| -------------------- | --------------------------- | ---------------- | ---------- |
| `contains` | Substring match (`*value*`) | Case-insensitive | 2 chars |
| `contains-not` | Inverse of `contains` | Case-insensitive | 2 chars |
| `startswith` | Prefix match (`value*`) | Case-insensitive | 1 char |
| `startswith-not` | Inverse of `startswith` | Case-insensitive | 1 char |
| `endswith` | Suffix match (`*value`) | Case-insensitive | 1 char |
| `endswith-not` | Inverse of `endswith` | Case-insensitive | 1 char |
| `matches-string` | Exact value match | Case-sensitive | 1 char |
| `matches-string-not` | Inverse of `matches-string` | Case-sensitive | 1 char |
Max value length for `contains` / `contains-not`: 128 chars.
OR across multiple values (default):
```json
{ "type": "event_condition", "key": "event-type", "operator": "matches-string", "values": ["create", "update"] }
```
AND across multiple values (for multi-value fields such as `audience-target-groups`):
```json
{
"type": "event_condition",
"key": "audience-target-groups",
"operator": "matches-string",
"values": ["&&", "vip", "newsletter"]
}
```
### Numeric
Apply to fields of type `INT` and `DECIMAL`.
#### `matches-number` / `matches-number-not`
Exact match against one or more numbers. Same `&&` / `||` semantics as the string operators.
```json
{
"type": "event_condition",
"key": "ecommerce_order_create.product-count",
"operator": "matches-number",
"values": [1, 2, 3]
}
```
#### `range-number` / `range-number-not`
Range query. `values` is an **object**, not an array:
```json
{
"type": "event_condition",
"key": "ecommerce_order_create.order-euro-amount",
"operator": "range-number",
"values": {
"lowerNumber": 100,
"upperNumber": 200,
"lowerExcludeEquals": false,
"upperExcludeEquals": false
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | -------------- | ------- | ------------------------------------------------------------------------ |
| `lowerNumber` | number \| null | `null` | Lower bound. `null` = unbounded below. |
| `upperNumber` | number \| null | `null` | Upper bound. `null` = unbounded above. |
| `lowerExcludeEquals` | bool | `false` | If `true`, lower bound is exclusive (`gt`); otherwise inclusive (`gte`). |
| `upperExcludeEquals` | bool | `false` | If `true`, upper bound is exclusive (`lt`); otherwise inclusive (`lte`). |
Both bounds `null` on `range-number` matches everything (and nothing on `range-number-not`).
### Date
Apply to fields of type `DATE`. All date operators honour the project timezone.
#### `matches-date`
Exact day match. Internally expanded to `[00:00:00, 23:59:59]` in the project timezone.
```json
{
"type": "event_condition",
"key": "created-at",
"operator": "matches-date",
"values": { "date": "2026-04-22" }
}
```
| Field | Type | Notes |
| ------ | --------------------- | ---------------------- |
| `date` | string (`YYYY-MM-DD`) | Required. Exactly one. |
#### `range-date` / `range-date-not`
Absolute date range.
```json
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date",
"values": {
"lowerDate": "2026-01-01",
"upperDate": "2026-03-31",
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": true,
"upperRounding": true
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | ------------------------- | ------- | ----------------------------------------------------------------- |
| `lowerDate` | ISO date/datetime \| null | `null` | Lower bound. `null` = unbounded below. |
| `upperDate` | ISO date/datetime \| null | `null` | Upper bound. `null` = unbounded above. |
| `lowerExcludeEquals` | bool | `false` | Exclude equality on lower bound (`gt` vs `gte`). |
| `upperExcludeEquals` | bool | `false` | Exclude equality on upper bound (`lt` vs `lte`). |
| `lowerRounding` | bool | `false` | If `true`, rounds the lower bound to the start of its day (`/d`). |
| `upperRounding` | bool | `false` | If `true`, rounds the upper bound to the end of its day (`/d`). |
#### `range-date-relative` / `range-date-relative-not`
Range expressed relative to *now*.
```json
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-relative",
"values": {
"lowerOffset": -30,
"upperOffset": 0,
"lowerOffsetPeriod": "day",
"upperOffsetPeriod": "day",
"lowerExcludeEquals": false,
"upperExcludeEquals": false,
"lowerRounding": false,
"upperRounding": false
}
}
```
| Field | Type | Default | Meaning |
| -------------------- | ----------- | ------- | ------------------------------------------------------------------------------- |
| `lowerOffset` | int \| null | `null` | Signed offset from now. Negative = past, positive = future. `null` = unbounded. |
| `upperOffset` | int \| null | `null` | Signed offset from now. |
| `lowerOffsetPeriod` | enum | `"day"` | Unit of `lowerOffset`. One of: `min`, `hour`, `day`, `week`, `month`, `year`. |
| `upperOffsetPeriod` | enum | `"day"` | Unit of `upperOffset`. Same options. |
| `lowerExcludeEquals` | bool | `false` | Same semantics as `range-date`. |
| `upperExcludeEquals` | bool | `false` | Same semantics as `range-date`. |
| `lowerRounding` | bool | `false` | Rounds the bound to the start of the period. |
| `upperRounding` | bool | `false` | Rounds the bound to the end of the period. |
#### `range-date-anniversary`
Matches date anniversaries independent of year (e.g. "happened in the next 7 days of the year", "falls between Jan 25 and Dec 01"). Only available on DATE fields backed by the anniversary index (event `created-at` and custom date parameters that opt in).
```json
// Relative mode — N days before/after today's anniversary
{
"values": {
"mode": "relative",
"lowerOffsetDays": 0,
"upperOffsetDays": 7
}
}
// Absolute mode — month-day range (format MMDD)
{
"values": {
"mode": "absolute",
"lowerDate": "0125",
"upperDate": "1201"
}
}
```
| Field | Type | Required when | Notes |
| ----------------- | ---------------------------- | ----------------- | --------------------------------------------------- |
| `mode` | `"relative"` \| `"absolute"` | always | Default `"relative"`. |
| `lowerOffsetDays` | int | `mode = relative` | Signed. Negative = before, positive = after. `gte`. |
| `upperOffsetDays` | int | `mode = relative` | Signed. Exclusive upper (`lt`). Default `1`. |
| `lowerDate` | string (`MMDD`) | `mode = absolute` | e.g. `"0125"` = Jan 25. |
| `upperDate` | string (`MMDD`) | `mode = absolute` | Inverted ranges wrap around the year boundary. |
#### `range-date-dayversary`
Matches a weekday + hour-of-day window. Only valid on `created-at`. Use it for recurring weekly schedules — e.g. "orders on Friday evenings".
```json
{
"type": "event_condition",
"key": "created-at",
"operator": "range-date-dayversary",
"values": { "lowerDay": "518", "upperDay": "523" }
}
```
| Field | Type | Format | Example |
| ---------- | ------ | -------------------------------------------------------------------------------- | ------------------------------ |
| `lowerDay` | string | `DHH` — 1 digit weekday (`1` = Monday … `7` = Sunday) + 2 digit hour (`00`–`23`) | `"518"` = Friday 18:00, `gte`. |
| `upperDay` | string | same | `"523"` = Friday 23:00, `lte`. |
If the lower day/hour is *greater* than the upper (e.g. Friday → Monday), the range wraps across the week boundary automatically.
#### `range-date-timeversary`
Matches a time-of-day window independent of the date.
```json
// Relative mode — N minutes around the current time
{
"values": {
"mode": "relative",
"lowerOffsetMinutes": -15,
"upperOffsetMinutes": 15
}
}
// Absolute mode — HH:MM range (format HHMM or HH:MM — colons are stripped)
{
"values": {
"mode": "absolute",
"lowerTime": "0900",
"upperTime": "1800"
}
}
```
| Field | Type | Required when | Notes |
| -------------------- | ---------------------------- | ----------------- | ------------------------------------- |
| `mode` | `"relative"` \| `"absolute"` | always | Default `"relative"`. |
| `lowerOffsetMinutes` | int | `mode = relative` | Signed, `gte`. |
| `upperOffsetMinutes` | int | `mode = relative` | Signed, `lt`. Default `1`. |
| `lowerTime` | string (`HHMM`) | `mode = absolute` | 24h. `"0900"` = 09:00. |
| `upperTime` | string (`HHMM`) | `mode = absolute` | Inverted ranges wrap across midnight. |
### Geographic
#### `geopoint-distance`
Matches events within a radius of a `(longitude, latitude)` point. Only applies to geopoint fields (custom event parameters configured as geopoint).
```json
{
"type": "event_condition",
"key": "store_visit.location",
"operator": "geopoint-distance",
"values": {
"longitude": -3.7038,
"latitude": 40.4168,
"distance": 25
}
}
```
| Field | Type | Required | Notes |
| ----------- | ------- | -------- | ----------------------------------- |
| `longitude` | decimal | yes | WGS-84 longitude. |
| `latitude` | decimal | yes | WGS-84 latitude. |
| `distance` | decimal | yes | Radius. Default unit is kilometres. |
All three fields are required; missing any raises a validation error.
### `values` shape — quick reference
| Operator | `values` shape | | |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | - | --- |
| `match-all`, `match-none` | ignored | | |
| `exists`, `exists-not` | `[]` | | |
| `matches-bool` | `[true]` or `[false]` | | |
| `contains(-not)`, `startswith(-not)`, `endswith(-not)`, `matches-string(-not)` | `string[]`, optional leading `"&&"` / \`" | | "\` |
| `matches-number(-not)` | `number[]`, optional leading `"&&"` / \`" | | "\` |
| `range-number(-not)` | `{ lowerNumber, upperNumber, lowerExcludeEquals?, upperExcludeEquals? }` | | |
| `matches-date` | `{ date: "YYYY-MM-DD" }` | | |
| `range-date(-not)` | `{ lowerDate, upperDate, lowerExcludeEquals?, upperExcludeEquals?, lowerRounding?, upperRounding? }` | | |
| `range-date-relative(-not)` | `{ lowerOffset, upperOffset, lowerOffsetPeriod, upperOffsetPeriod, lower/upperExcludeEquals?, lower/upperRounding? }` | | |
| `range-date-anniversary` | `{ mode: "relative", lowerOffsetDays, upperOffsetDays }` or `{ mode: "absolute", lowerDate: "MMDD", upperDate: "MMDD" }` | | |
| `range-date-dayversary` | `{ lowerDay: "DHH", upperDay: "DHH" }` (only on `created-at`) | | |
| `range-date-timeversary` | `{ mode: "relative", lowerOffsetMinutes, upperOffsetMinutes }` or `{ mode: "absolute", lowerTime: "HHMM", upperTime: "HHMM" }` | | |
| `geopoint-distance` | `{ longitude, latitude, distance }` | | |
## Date histogram parameters
| Parameter | Type | Description | Example values |
| ------------------- | ------- | ---------------------------------- | --------------------------------------------------- |
| `field` | string | Date field to aggregate on. | `@created-at`, `@ecommerce_order_create.created-at` |
| `calendar_interval` | string | Calendar-aware intervals. | `1d`, `1w`, `1M`, `1y` |
| `fixed_interval` | string | Fixed time intervals. | `1h`, `6h`, `30m`, `1d` |
| `format` | string | Date format for bucket keys. | `yyyy-MM-dd`, `HH:00`, `E` |
| `time_zone` | string | Timezone for date calculations. | `UTC`, `Europe/Madrid`, `America/New_York` |
| `offset` | string | Time offset for bucket boundaries. | `+1h`, `-30m` |
| `min_doc_count` | integer | Minimum document count per bucket. | `0`, `1` |
| `extended_bounds` | object | Extend bounds beyond data range. | `{"min":"2024-01-01","max":"2024-12-31"}` |
## Terms aggregation parameters
| Parameter | Type | Description | Example values |
| --------------- | ------------ | ---------------------------------------- | ------------------------------------------------------ |
| `field` | string | Field to group by. | `@product-name`, `@event-type`, `@audience-categories` |
| `size` | integer | Number of top terms to return. | `5`, `10`, `100` |
| `missing` | string | Label for documents with missing values. | `"unknown"`, `"N/A"` |
| `order` | object | Sort order for terms. | `{"salesCount":"desc"}`, `{"_count":"asc"}` |
| `min_doc_count` | integer | Minimum document count per term. | `1`, `5` |
| `include` | array/string | Include only specific terms. | `["product1","product2"]` |
| `exclude` | array/string | Exclude specific terms. | `["excluded1","excluded2"]` |
## What's next
- **[Audience probes](/platform-api/product-api/audience/probes)**: find out which event types this project actually receives, when the last one arrived, and what values their parameters carry, before you filter on any of them.
- **[Audience query filter](/platform-api/product-api/audience/query-filter)** (AQF) — the contact-side filter, with segment and group\_event conditions.
- **[API Reference](/platform-api/product-api/reference)** — endpoints that accept this filter (`/event/search`, `/event/scroll`, `/event/aggregations`, and `/event/volumes` to probe the events it selects).
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/probes
# 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).
**Language:** en
**Audience:** developer
**TLDR:** Probes measure shape, never size. POST /project/{project}/audience/coverage returns per-attribute fill rate, distinct values and top values; POST /project/{project}/event/volumes returns per-event-type counts, lastSeenAt, and (with `event`) the real parameter values, which ARE sampled above 10,000 contacts and report it in event.sampling, not in the top-level one. Both take PROJECT_AUDIENCE_READ. Probe to pick a value, then use /audience/count for any number you quote.
**Search keywords:** probe, probes, audience probe, attribute coverage, coverage, fill rate, filled, empty attribute, unfilled, distinct values, cardinality, top values, most frequent values, vocabulary, event volumes, volumes, event types received, lastSeenAt, last seen, never received, audience analysis, analyse an audience, analyze audience, profile the audience, data quality, what data do I have, what is in my audience, which attributes are usable, discovery, exploration, sampling, sampled, estimate, estimated, approximate, refresh, throttled, empty segment, segment matches nobody, filter returns nothing, zero contacts, no matches, silent empty filter, event parameters, parameter values, product category values, topValuesOmitted, denominator, event.sampling, two sampling blocks, observedCount, reliable, detection floor, scaled count, is the data exact, are the numbers exact, id vs name, product-id, product-name, utm-id, utm-campaign, filter by id, campaign id, product id, value missing from the list, absence, preferred source, preferred datasource, which source sends this event, duplicate events, relayed orders, platformEventTypes, platform events, distinctAtLeast, fillRate, parameter fill rate, refreshing
**Related pages:** /platform-api/product-api/audience/query-filter, /platform-api/product-api/audience/event-query-filter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/probes/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/probes.md (Markdown)
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.
> **Warning**: A probe tells you a value **exists** and is worth filtering on. It never tells you **how many people it reaches**. Its figures describe shape, they may be sampled estimates, and they must not be quoted as an audience size. For a size, post the filter to [`/audience/count`](/platform-api/product-api/audience/counting-and-retrieval#counting), which is exact and live.
## The two probes
[`POST /project/{project}/audience/coverage` - Attribute coverage: which of the attributes this project declares are actually usable.](/platform-api/product-api/reference)
[`POST /project/{project}/event/volumes` - Event volumes: which event types this project actually receives, and what values their parameters carry.](/platform-api/product-api/reference)
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](/platform-api/product-api/rate-limits) class whose ceiling follows your plan.
They are the companion of the specs endpoints: the [attribute catalogue](/platform-api/product-api/audience/contacts-and-attributes#the-attribute-catalogue) and the [event catalogue](/platform-api/product-api/audience/events#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:
#### 1. Probe
Ask coverage which attributes are filled and what values they hold, or ask volumes which event types arrive and what their parameters carry.
#### 2. Read the real value
Take the literal string the data uses, not the one your product vocabulary uses.
#### 3. Compose the filter
Write the [audience query filter](/platform-api/product-api/audience/query-filter) against that attribute and that value.
#### 4. Count
Post it to [`/audience/count`](/platform-api/product-api/audience/counting-and-retrieval#counting). 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.
- `root` — `object`
An [audience query filter](/platform-api/product-api/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.
- `datasource` — `string`
A 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.
- `attributes` — `string[]`
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.
- `refresh` — `boolean`, default: `false`
Force a recomputation. Throttled to one every five minutes; a denial is not an error.
```bash
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).
```json
{
"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
- `fillRate` — `number`
The 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.
- `filled` — `integer`
`fillRate` 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.
- `observedFilled` — `integer`
The 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.
- `approximate` — `boolean`
Whether the figures for this attribute came from a sample.
- `reliable` — `boolean`
Whether `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.
- `distinctValues` — `integer`
Estimated 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.
- `topValues` — `object[]`
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.
- `topValuesOther` — `integer`
Contacts holding a value outside the ten listed. Above zero, the list is the most frequent values and not all of them.
- `topValuesOmitted` — `string`
Present **instead of** `topValues`, saying why they were withheld. See [The absence of top values is not an absence of values](#the-absence-of-top-values-is-not-an-absence-of-values).
- `topValuesSemantics` — `string`
Present on multivalue attributes. The counts are contacts **holding** the value, and one contact can hold several, so they do not sum to the audience.
- `stats` — `object`
Numeric and date ranges: `avg`, plus `min` and `max` when nothing was sampled. Under sampling the extremes are replaced by `extremesOmitted`.
- `summary` — `string`
The 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:
```bash
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:
```json
{
"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
- `root` — `object`
An [audience event query filter](/platform-api/product-api/audience/event-query-filter) root. Narrows the events being probed. A call carrying a root is computed live and never cached.
- `datasource` — `string`
A connector name, uid or id. Keeps events this data source **produced**.
- `window` — `integer`
Days to look back. Omit for the default, which is wider on small audiences. Whatever results is clamped by the subscription.
- `event` — `string`
One event type uid. Adds a second block carrying the values that type's parameters actually hold.
- `refresh` — `boolean`, default: `false`
Force a recomputation, throttled to one every five minutes.
```bash
curl -X POST "$BASE/event/volumes" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
```
### The response
```json
{
"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,
"datasources": [
{ "id": "66c3a1b2e5a6b7c8d9e0f1a2", "name": "My Shop", "type": "integration", "integration": "shopify", "count": 6480, "lastSeenAt": "2026-08-28T08:57:02+00:00", "preferred": true },
{ "id": "66c3a1b2e5a6b7c8d9e0f1c9", "name": "Newsletter tool", "type": "integration", "integration": "klaviyo", "count": 6414, "lastSeenAt": "2026-08-28T08:51:40+00:00", "preferred": false }
]
},
{
"uid": "ecommerce_checkout_abandon",
"label": "Checkout abandoned",
"count": 0,
"received": false,
"lastSeenAt": null,
"effectiveWindowDays": 15,
"windowLimitedByRetention": false,
"datasources": []
}
],
"platformEventTypes": [
{
"uid": "update",
"label": "Contact updated",
"count": 3120,
"received": true,
"lastSeenAt": "2026-08-28T09:02:44+00:00",
"effectiveWindowDays": 15,
"windowLimitedByRetention": false,
"datasources": [
{ "id": "66c3a1b2e5a6b7c8d9e0f1a2", "name": "My Shop", "type": "integration", "integration": "shopify", "count": 3120, "lastSeenAt": "2026-08-28T09:02:44+00:00", "preferred": 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](#the-response-carries-two-sampling-blocks).
### Where each event comes from
Each entry in `eventTypes` carries `datasources`: the sources that sent that type inside the window, **most authoritative first**, each with its `id`, `name`, `type`, `integration`, its own `count` and `lastSeenAt`, and a `preferred` flag.
The flag exists because two sources often send the same fact. A shop and a marketing tool that relays the shop's orders both deliver `ecommerce_order_create`, with their own ids, and nothing merges the two copies. For commerce events, the shop that owns the orders ranks first and a tool that relays them ranks last; for every other type there is no owner of the fact, so the fresher and then the busier source ranks first. **At most one source per type is `preferred`**: the highest-ranked one that can trigger automations. It is the one a [flow trigger](/platform-api/product-api/flows/triggers-and-exits#the-event-and-its-source) should listen to. A source that was deleted keeps its `id` with `name`, `type` and `integration` set to `null`, sorts last and is never preferred.
`platformEventTypes` has the same shape and holds Instasent's own activity on the audience: sends, opens, tracked-link clicks, subscribes and unsubscribes, and `update`, raised when a contact changes (including joining a list or gaining a tag). They are listed apart because they are the highest-volume types — one row per contact per send — and would bury the audience's own activity. Several of them can start a flow, but **none of their sources is ever `preferred`**: they are raised on whatever source the contact belongs to, not by a source that owns the fact.
When another request is already recomputing the answer, the response is the previous one, with `refreshing: true`; `computedAgo` says how old it is.
### 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.
> **Tip**: Where a thing has both an id and a name, both halves come back and they are not interchangeable. **Filter on the id** (`product-id`, `utm-id`): it is stable and unique. **Read the name** (`product-name`, `utm-campaign`): it is neither. A condition written against a name breaks the moment the customer renames the product, and a campaign title reused across campaigns quietly matches more than one.
```bash
curl -X POST "$BASE/event/volumes" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "event": "ecommerce_order_create" }'
```
```json
{
"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"
}
}
}
}
```
Each parameter also says how well it is filled. `distinctAtLeast` is the number of distinct values seen, a floor when `truncated`. When any event was observed, `fillRate` (0 to 1), `filled` and `missing` say what share of this type's events carry the parameter at all. A `fillRate` of 0 means the source never fills it: many stores send no product tags, for example, and a filter or a flow trigger on that parameter would match nothing.
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](#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.
#### 1. Is the event even arriving?
Post an empty body to `/event/volumes`. `ecommerce_order_create` shows 12,894 in the window and a `lastSeenAt` of this morning, so the type is live. Had it come back `count: 0` with a null `lastSeenAt`, the honest next step would be widening `window`, not concluding the shop sells nothing.
#### 2. What are the real category names?
Probe again with `"event": "ecommerce_order_create"`. `product-category` holds `Skincare`, `Fragrance`, `Haircare` and more (`other` is above zero). The catalogue is capitalised and singular, so a filter written against `skincare` or `Skin care` would have matched nobody. Had you wanted one specific product rather than a category, the same answer carries `product-id` next to `product-name`: take the id for the condition and keep the name for the sentence you write around it.
#### 3. Compose the filter
Write a `group_event` condition on `ecommerce_order_create` with `product-category` matching `Skincare` over your timeframe. The grammar is on the [audience query filter](/platform-api/product-api/audience/query-filter#complex-event-filtering) page.
#### 4. 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 `sampling`** describes the per-type counts, and says so in its own `covers` key. It is always `applied: 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. Its `parameterValues` key points at the other block.
- **`event.sampling`**, present only when you passed `event`, 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.
> **Warning**: A value **missing from a sampled list has not been shown not to occur**. Absence is not evidence here, and neither is `reliable: false`: it says the sample was too thin to measure the value dependably, never that the value is a zero. A `root` does not buy you a better sample either: unlike `/audience/coverage`, where the band follows the population you actually narrowed to, the event side takes its fraction from the project's whole audience. If a rare value matters, write the condition and post it to `/audience/count`, which is exact and never sampled.
### `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.
> **Warning**: Use the event probe to **pick a value**, never to promise a size. Once you have the value, post the filter to `/audience/count` and quote that.
## What a probe is not
Four neighbouring surfaces, deliberately kept apart:
- **[`/audience/count`](/platform-api/product-api/audience/counting-and-retrieval#counting)** takes 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/aggregations`** runs *your* aggregations over the audience and needs the manually granted `PROJECT_AGGREGATIONS` scope. A probe builds its own fixed aggregations and needs only `PROJECT_AUDIENCE_READ`.
- **[Analytics](/platform-api/product-api/analytics/overview)** 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/attributes` and `GET /specs/events`** return the project's declared schema, described in [Contacts and attributes](/platform-api/product-api/audience/contacts-and-attributes) and [Events](/platform-api/product-api/audience/events). Probes measure how much of that schema has data behind it.
## What's next
- **[Audience query filter](/platform-api/product-api/audience/query-filter)** (AQF): the grammar you write once the probe told you which attribute and which value, and the same grammar `/audience/coverage` accepts as `root`.
- **[Audience event query filter](/platform-api/product-api/audience/event-query-filter)** (EQF): the event-side grammar, and the `root` `/event/volumes` accepts.
- **[Counting and retrieval](/platform-api/product-api/audience/counting-and-retrieval)**: where the number you may actually quote comes from.
- **[Segments](/platform-api/product-api/audience/segments)**: keeping the filter the probe helped you write.
- **[Full API Reference](/platform-api/product-api/reference)**: request and response shapes for both endpoints.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/counting-and-retrieval
# Counting and retrieval
Which endpoint answers which question about the audience: the exact count of a filter, a quick search, a cursor traverse of a large result set, a single contact by id or identifier, and what the subscription plan lets each of them return.
**Language:** en
**Audience:** developer
**TLDR:** Use /audience/count for an exact total with no rows (works under the anonymized data policy, and takes filterCompliance to count who can actually receive). Use /audience/search for a first page of up to 50 contacts and /audience/scroll for large traverses with a cursor, up to 100 per page; both need PROJECT_AUDIENCE_LIST for scroll and are redacted by plan. Fetch one contact by audience id, user id, phone or email with the direct lookups.
**Search keywords:** count, count contacts, how many contacts, audience count, total contacts, search contacts, find contacts, list contacts, scroll, cursor, pagination, paginate, iterate contacts, export contacts, bulk read, retrieve contact, get contact, lookup by phone, lookup by email, lookup by user id, audience id, aggregations, anonymized, data policy, redacted, PII, which endpoint should I use, 50 limit, 100 limit, filterCompliance, reachable
**Related pages:** /platform-api/product-api/audience/query-filter, /platform-api/product-api/audience/segments
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/counting-and-retrieval/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/counting-and-retrieval.md (Markdown)
Once a filter selects the right people, there are four different things you might want back, and they are four different endpoints with four different costs and permissions. Picking the wrong one is the usual cause of a `403` you did not expect or a page-two that never arrives.
## Which endpoint for which question
| The question | Endpoint | What comes back |
| ----------------------------- | ------------------------------------------------------- | ------------------------------------- |
| How many match this filter? | `POST /audience/count` | A single exact total, no contact rows |
| Show me a first page | `POST /audience/search` | Up to 50 contacts, from the beginning |
| Give me all of them | `POST /audience/scroll` | Up to 100 per page, cursor-paginated |
| All of them, inside a segment | `POST /audience/segment/{uid}/scroll` | Same, restricted to a segment |
| This one person | `GET /audience/{audienceId}` and the identifier lookups | One contact |
| A breakdown, not rows | `POST /audience/aggregations` | Aggregation results only |
## Counting
`POST /project/{project}/audience/count` takes the [audience query filter](/platform-api/product-api/audience/query-filter) and returns only the total. It is the endpoint whose number you may act on: exact, uncapped and live, with no sampling anywhere near it.
[`POST /project/{project}/audience/count` - The exact number of contacts matching a filter.](/platform-api/product-api/reference)
```bash
curl -X POST "$BASE/audience/count" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "filterCompliance": { "sms": "no-opt-out" } }'
```
Four things worth knowing:
- **An empty body counts the whole audience.** `limit` and `offset` are ignored.
- **It carries no contact data**, which is why it stays available under the `anonymized` data policy mode, where `/audience/search` is disabled. For restricted credentials it is the supported way to answer "how many contacts match this".
- **To count who can actually *receive*, pass `filterCompliance`.** Without it the total is the raw audience, not a sending figure. The consent and reach rules are the same ones a campaign estimate applies. See [Counting who can receive](/platform-api/product-api/audience/query-filter#counting-who-can-receive-filtercompliance).
- **It is the endpoint that settles any number a probe suggested.** [Probes](/platform-api/product-api/audience/probes) report shape and may be sampled; this is the size.
## Searching and scrolling
Both take the same filter. They differ in how much they will give you and what it costs.
[`POST /project/{project}/audience/search` - A first page of matching contacts, up to 50.](/platform-api/product-api/reference)
`/audience/search` uses offset/limit pagination, but the offset is always forced to `0`: it is built for a quick look from the beginning, not for walking a result set. Its ceiling is 50 contacts per request and it needs `PROJECT_AUDIENCE_READ`.
[`POST /project/{project}/audience/scroll` - A cursor traverse of the whole result set, 100 contacts at a time.](/platform-api/product-api/reference)
`/audience/scroll` is the one for large traverses. It returns a cursor in the response metadata that you pass back to get the next page, up to 100 contacts per request. The cursor is a base64 string holding the internal query state and **expires after one minute of inactivity**, so a traverse has to keep moving: a job that fetches a page, spends five minutes processing it and comes back will find the cursor gone.
It needs `PROJECT_AUDIENCE_LIST`, which is gated by the subscription plan. That is the usual reason a call that works for one organization is refused for another with the same code.
> **Tip**: Scroll rather than paginate. It is the endpoint designed for volume: one hit per page, generous page size and no offset arithmetic. Reserve `/audience/search` for the interactive "show me a few" case.
## Retrieving one contact
When you already know who you are looking for, four direct lookups skip the filter entirely.
[`GET /project/{project}/audience/{audienceId}` - By audience contact id.](/platform-api/product-api/reference)
[`GET /project/{project}/audience/user/{userId}` - By the `_user_id` your systems use.](/platform-api/product-api/reference)
[`GET /project/{project}/audience/search/phone/{userPhone}` - By phone number. URL-encode the leading plus as `%2B`.](/platform-api/product-api/reference)
[`GET /project/{project}/audience/search/email/{userEmail}` - By email address.](/platform-api/product-api/reference)
The id these return is the one every other per-contact call takes, including [the contact's events](/platform-api/product-api/audience/events#reading-a-contacts-events) and direct messaging.
## Aggregations
`POST /audience/aggregations` runs **your** aggregations over the filtered audience and returns results only, with no contact rows and the limit pinned to zero. It needs `PROJECT_AGGREGATIONS`, which is not generally available and is granted manually by Instasent.
If what you want is a distribution over an attribute rather than a bespoke aggregation, [`/audience/coverage`](/platform-api/product-api/audience/probes) gives you one with the ordinary read scope, at the cost of being a probe: shape, possibly sampled, never a size.
## What the plan lets you see
Two gates decide the fields on a returned contact, and both have to be open: the **scopes** on your token and the **subscription plan** of the organization.
| Level | Scope | Fields returned |
| ------- | ----------------------------- | ------------------------------------------- |
| Default | `PROJECT_AUDIENCE_READ` | Full name and user id only |
| Basic | `PROJECT_AUDIENCE_DATA_BASIC` | Phone, email, country, name, boolean fields |
| Full | `PROJECT_AUDIENCE_DATA_FULL` | Full contact data including PII |
> **Warning**: A field missing from a response is far more often a plan or scope limit than an empty value. Before concluding the data is not there, check the level you are actually getting: contact data is **redacted down**, silently and by design, not refused. `/audience/count` is unaffected, because it returns no contact data at all.
## What's next
- **[Query filter](/platform-api/product-api/audience/query-filter)**: the filter these endpoints take, including cursor mechanics and aggregation shapes.
- **[Segments](/platform-api/product-api/audience/segments)**: scrolling a saved segment, and reading its cached size.
- **[Probes](/platform-api/product-api/audience/probes)**: check the filter is selecting on something real before you count it.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/segments
# Segments
A segment is a saved audience filter the project keeps by name: how to create one, read it back, understand its cached size, scroll its contacts, reference it from another filter, and audit where it is actually used.
**Language:** en
**Audience:** developer
**TLDR:** A segment stores a filter, not a list of contacts, so its membership is recomputed when it is used. POST /project/{project}/segment saves a queryFilter under a name (PROJECT_SEGMENT_WRITE, name max 50 chars, description max 256). Sizes on list rows are cached and a null size means unknown, not empty. Scroll a segment with POST /audience/segment/{uid}/scroll, reference one from another filter with an in-segment condition, and find orphans with GET /segment/usage.
**Search keywords:** segment, segments, save a segment, create segment, named segment, dynamic segment, static segment, segment size, how many contacts in a segment, segment membership, segment contacts, scroll segment, in-segment, segment condition, reuse a filter, saved filter, segment usage, orphan segment, unused segment, stale segment, audit segments, queryFilter, PROJECT_SEGMENT_WRITE, target a segment, campaign audience
**Related pages:** /platform-api/product-api/audience/query-filter, /platform-api/product-api/audience/counting-and-retrieval
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/segments/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/audience/segments.md (Markdown)
A filter you post to `/audience/search` or `/audience/count` lives for one request. Give it a name and the project keeps it: that is a **segment**. A campaign can target it, an automation can enroll from it, another filter can reference it, and you can ask for its current size whenever you want.
> **Warning**: **A segment does not hold contacts. It holds a question.** Membership is derived from the stored filter every time the segment is used, so a segment created today selects different people next month without anyone editing it. Nothing in the API returns "the contacts that were in this segment when it was created", because that set was never stored.
## Saving a filter
[`POST /project/{project}/segment` - Save an audience filter as a named segment the project keeps.](/platform-api/product-api/reference)
```json
{
"name": "Recent Yahoo contacts",
"description": "Contacts with a Yahoo email imported in the last 90 days.",
"queryFilter": {
"version": "0.0.1",
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_email",
"operator": "contains",
"values": ["yahoo"]
},
{
"type": "attribute_condition",
"key": "_date_imported",
"operator": "range-date-relative",
"values": {
"lowerOffset": -90,
"lowerOffsetPeriod": "day",
"upperOffset": 0,
"upperOffsetPeriod": "day"
}
}
]
}
}
}
```
`name` (the label the segment carries in the dashboard, max 50 characters) and `queryFilter` are required; `description` (max 256 characters) is optional. The response returns the stored segment.
`queryFilter` takes exactly the [audience query filter](/platform-api/product-api/audience/query-filter) grammar and is validated the same way: a root group whose children are attribute conditions, event groups or segment memberships. An invalid filter is refused with a `422` naming the offending node, and nothing is saved.
Saving needs `PROJECT_SEGMENT_WRITE`. It is a scope of its own, so a token can be allowed to keep segments without being given write access to the audience. Reading segments back takes `PROJECT_AUDIENCE_READ` instead. See [Tokens and scopes](/platform-api/product-api/guide#tokens-and-scopes).
> **Tip**: Compose the filter against `/audience/count` before you save it, and [probe](/platform-api/product-api/audience/probes) the attributes it uses before that. A segment saved on a condition that matches nobody is indistinguishable, from the outside, from one that works.
> **Note**: Consent and channel reach do not belong inside a saved segment. They are decided when something is sent, under the project's policy for that channel, so a segment stores who the contacts **are** and not who may be messaged. Keep `filterCompliance` for the count and leave it out of the `queryFilter` you save.
## Reading segments back
[`GET /project/{project}/segment` - List the project's segments, with query-string filtering, sorting and pagination.](/platform-api/product-api/reference)
[`GET /project/{project}/segment/{uid}` - One segment, including its stored filter.](/platform-api/product-api/reference)
[`GET /project/{project}/segment/dynamic` - Only the dynamic segments that take no parameter.](/platform-api/product-api/reference)
The list endpoint accepts the [generic query filter](/platform-api/further-reading/query-filter) conventions: `field_operator=value` for filtering (`name_eq=MySegment`, `type_eq=static`), `_sort=field:direction` for ordering, and `_start` plus `_limit` for pagination. When you pass `_limit` you must also pass `_start`.
### Dynamic segments and their parameter
Some segments are **dynamic**: they carry a parameter that is supplied when the segment is used, so one definition covers a family of audiences. Pass it as the `parameter` query argument, pipe-separated for several values (`parameter=val1|val2`), both when viewing the segment and when scrolling it.
### The size on a segment is cached
Every segment view and list row carries a `contacts` object with the segment's size and the age of that figure.
> **Warning**: **A null size means "not known yet", never "empty".** The list endpoints serve whatever the cache already holds and never compute, so any segment whose size has never been computed or has expired comes back null. The single-segment view computes it. For parameterized dynamic segments it is always null, because the size depends on the parameter.
`totalContacts` repeats the same number for older clients. Prefer `contacts`, which carries the age: a bare total invites reading a cached number as a live one. When you need a number you can act on, count the segment's filter through [`/audience/count`](/platform-api/product-api/audience/counting-and-retrieval).
## Working with a segment's contacts
[`POST /project/{project}/audience/segment/{uid}/scroll` - Cursor-scroll the contacts of one segment.](/platform-api/product-api/reference)
The segment's own filter is **merged into** whatever you post, so a body with a `root` filters *within* the segment rather than replacing it. Everything else behaves like [`/audience/scroll`](/platform-api/product-api/audience/counting-and-retrieval#searching-and-scrolling): `PROJECT_AUDIENCE_LIST`, up to 100 contacts per page, a cursor that expires after one minute of inactivity, and contact fields redacted according to plan and scopes.
To reference a segment from another filter instead of reading its rows, use a `segment_condition` with the `in-segment` or `in-segment-not` operator. That is how a filter says "these people, but not the ones already in the VIP segment". The grammar is on the [query filter](/platform-api/product-api/audience/query-filter#filter-by-segment-membership) page.
## Finding the segments nobody uses
Segments accumulate. `GET /project/{project}/segment/usage` returns, in one request, where each one is actually referenced.
[`GET /project/{project}/segment/usage` - Campaigns that have targeted each segment and automations that enroll from it.](/platform-api/product-api/reference)
Per segment it reports `campaigns`, `lastTargetedAt`, `automations` and `activeAutomations`, plus a `computedAt` for the whole map, which is aggregated per project and cached ten minutes.
The interesting part is what it leaves out. **Only segments with at least one reference are returned**, so a segment that appears in the list endpoint and not here is an orphan, and that absence is the point: it makes an audit possible in one request instead of one per segment. Combined with the cached size on each list row, two calls find the orphaned, the empty and the stale.
A segment kept alive only through `automations` that are all inactive is effectively unused too, which is why `activeAutomations` is reported separately. Flows carry no segment reference and are deliberately not counted here.
## What's next
- **[Query filter](/platform-api/product-api/audience/query-filter)**: the grammar a segment stores.
- **[Counting and retrieval](/platform-api/product-api/audience/counting-and-retrieval)**: getting a live number, and reading contacts out.
- **[Campaign audience targeting](/platform-api/product-api/campaigns/audience)**: pointing a campaign at a segment or at a filter.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/overview
# Campaigns
How the Product API models a campaign: the five endpoints it exposes, the draft-only contract behind all of them, and the statuses a campaign moves through. Start here before creating, reading, estimating or deleting one.
**Language:** en
**Audience:** developer
**TLDR:** The Product API drafts campaigns and never sends them: POST creates a draft, GET reads one back (plus a project-wide digest at /campaign/summary), PATCH .../estimate prices it, DELETE removes it. Confirming the send stays with a person in the dashboard. Every operation is gated by the campaign's status, and the twenty-odd statuses collapse into six phases: draft, preparing, scheduled, sending, sent and stopped.
**Search keywords:** campaign, campaigns, campaign api, bulk sms, mass sms, broadcast, blast, campaign status, campaign statuses, campaign phase, lifecycle, PROJECT_CAMPAIGN_WRITE, PROJECT_CAMPAIGN_READ, what can I do to a campaign
**Related pages:** /platform-api/product-api/campaigns/creating-a-draft, /platform-api/product-api/campaigns/reading-campaigns, /platform-api/product-api/campaigns/estimating-and-deleting
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/overview/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/overview.md (Markdown)
A campaign is a one-off message sent to a group of contacts: a sale announcement, a service notice, a launch. The Product API lets you assemble one programmatically, price it, read it back and throw it away. What it does not let you do is send it.
This is the surface an integration or an AI assistant uses to turn "message our VIP customers about the sale on Monday" into something a marketer can open, check and confirm.
## Drafts only, and no edit counterpart
Two properties decide how you design around this whole section.
> **Warning**: **The API drafts, a person sends.** Nothing here arms, schedules or sends a campaign, no balance is spent, and no message reaches a contact. Confirming the send happens in the dashboard, where a person has seen the estimate.
**There is no update endpoint.** A draft cannot be patched, and re-posting creates a second campaign rather than replacing the first. To change a campaign after it exists, someone opens it in the dashboard editor. Build your integration accordingly: assemble everything you know in one call, and treat the response as final from the API's point of view.
The upside of that split is the safety it buys. A programmatic caller, including an agent composing a campaign from a conversation, can go as far as a complete, reviewable draft, priced and checked, without ever being able to spend money or reach a customer by accident.
## The five endpoints
| Endpoint | What it does | Scope | Rate limit |
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------ | ----------- |
| `POST /campaign` | Creates a draft. See [Creating a draft](/platform-api/product-api/campaigns/creating-a-draft). | `PROJECT_CAMPAIGN_WRITE` | medium |
| `GET /campaign/{id}` | Reads one campaign back. See [Reading campaigns](/platform-api/product-api/campaigns/reading-campaigns). | `PROJECT_CAMPAIGN_READ` | large |
| `GET /campaign` | Lists the project's campaigns, filtered and paged. | `PROJECT_CAMPAIGN_READ` | large |
| `GET /campaign/summary` | A digest of the whole project's campaign activity. | `PROJECT_CAMPAIGN_READ` | large |
| `PATCH /campaign/{id}/estimate` | Prices a draft, asynchronously. See [Estimating and deleting](/platform-api/product-api/campaigns/estimating-and-deleting). | `PROJECT_CAMPAIGN_WRITE` | restrictive |
| `DELETE /campaign/{id}` | Removes a campaign that has not gone out. | `PROJECT_CAMPAIGN_WRITE` | restrictive |
Scopes are chosen when the token is minted, under **Project settings** → **API tokens**. See [Authentication](/platform-api/product-api/authentication) and [Rate limits](/platform-api/product-api/rate-limits), where each class ceiling is set by the organization's subscription plan.
> **Tip**: **The read endpoints are not optional extras.** Estimating and deleting are both asynchronous: neither has finished the work by the time it answers you, so in both cases the way you learn the outcome is to read the campaign again. An integration that only writes cannot tell whether either one worked.
## The life of a campaign
```mermaid
stateDiagram-v2
[*] --> Draft: created by the API
Draft --> Prepared: audience and message reviewed in the dashboard
Prepared --> Scheduled: confirmed in the dashboard
Scheduled --> Sent: the send runs
Sent --> [*]
Draft --> Deleted: deleted through the API
Prepared --> Deleted: deleted through the API
Scheduled --> Deleted: deleted through the API, up to five minutes before the send
Deleted --> [*]
class Sent success
class Deleted destructive
```
A draft created through the API lands in the project's campaign list exactly as if it had been started in the panel: same editor, same three steps (Audience, Message, Review & schedule), same estimate and confirmation. The customer-facing walkthrough of what happens from there is [Creating a campaign](/platform/en/campaigns/creating-a-campaign) in the Platform guides.
## Statuses and phases
Every operation in this section is gated by the campaign's `status`, and the errors you get are mostly status errors, so it is worth reading the vocabulary once.
There are twenty-odd raw statuses. They collapse into **six phases**, which is how the product talks about a campaign and what [`GET /campaign/summary`](/platform-api/product-api/campaigns/reading-campaigns#the-project-digest) counts by:
| Phase | Raw statuses | What it means |
| ----------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `draft` | `draft`, `preview`, `estimating`, `estimated` | Being written. Nothing is committed. |
| `preparing` | `quoting`, `quoted`, `preparing`, `prepared`, `unpreparing` | Confirmed by a person and being built. The price is locked and the individual messages are being assembled. |
| `scheduled` | `confirmed`, `scheduled` | Armed and waiting for its send time. |
| `sending` | `sending`, `continuing`, `resuming` | Going out now. |
| `sent` | `sent` | Done. |
| `stopped` | `canceled`, `aborted`, `unpaid`, `deleted` | Ended without completing, deletions included. |
Two of those mappings are worth internalising, because they are not what the names suggest:
- **`estimating` and `estimated` are still `draft`.** Pricing happens while a person is still editing, so an estimate does not move a campaign forward. It is not a step towards sending.
- **`prepared` is already `preparing`, not a draft.** A prepared campaign has been confirmed by a person and has its messages built. This is why [estimating a prepared campaign is destructive](/platform-api/product-api/campaigns/estimating-and-deleting#estimating-a-draft): it throws that work away.
> **Note**: Not everything stored as a campaign is one. One-off direct sends carry `purpose: direct` instead of `standard`, are not campaigns in the dashboard sense, and are [filtered out of the campaign list](/platform-api/product-api/campaigns/reading-campaigns#what-the-list-leaves-out) on purpose.
## What's next
- [Creating a draft](/platform-api/product-api/campaigns/creating-a-draft) - The `POST` contract: the request body, the response, warnings, limits and error codes.
- [Audience targeting](/platform-api/product-api/campaigns/audience) - Segments, inline filters and how to count the reach before you create anything.
- [Message content](/platform-api/product-api/campaigns/message) - Copy, template variables, senders, RCS buttons, SMS fallback and languages.
- [Dates and scheduling](/platform-api/product-api/campaigns/scheduling) - The three date forms, timezones, and why setting a date schedules nothing.
- [Reading campaigns](/platform-api/product-api/campaigns/reading-campaigns) - One campaign, the filtered list, and the project digest a dashboard is built on.
- [Estimating and deleting](/platform-api/product-api/campaigns/estimating-and-deleting) - Pricing a draft and removing one. Both asynchronous, both gated by status.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/creating-a-draft
# Creating a draft
The POST contract for campaign drafts: what the smallest useful request looks like, the full request body field by field, the warnings the response carries, the limits and the error codes an integration has to handle.
**Language:** en
**Audience:** developer
**TLDR:** POST /v1/project/{project}/campaign creates a DRAFT and nothing else, and there is no edit counterpart: re-posting creates a second campaign. Only channel and title are required; everything else defaults (the whole audience, the project's default sender, no date). The 201 returns the campaign under entity and non-fatal findings under metadata.warnings, where no-default-sender means the draft cannot be sent or even estimated until someone picks a sender.
**Search keywords:** create campaign, campaign draft, draft campaign, POST campaign, new campaign, campaign request body, campaign fields, campaign limits, campaign errors, errorCode, warnings, no-default-sender, PROJECT_CAMPAIGN_WRITE
**Related pages:** /platform-api/product-api/campaigns/overview, /platform-api/product-api/campaigns/audience, /platform-api/product-api/campaigns/message
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/creating-a-draft/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/creating-a-draft.md (Markdown)
One call assembles a whole campaign: pick the channel, describe who it targets, write the copy, and put it on the calendar. What comes back is a **draft**, ready for a human to review and send from the dashboard.
[`POST /v1/project/{project}/campaign` - Create a campaign draft.](/platform-api/product-api/reference)
Nothing on this page arms, schedules or sends anything, and there is no update endpoint to follow it with. Both properties are explained in [Campaigns](/platform-api/product-api/campaigns/overview#drafts-only-and-no-edit-counterpart).
## Before you start
#### 1. Get a Product API token with campaign write access
The endpoint requires the `PROJECT_CAMPAIGN_WRITE` scope. Scopes are chosen when the token is minted, under **Project settings** → **API tokens** — see [Authentication](/platform-api/product-api/authentication).
#### 2. Know your project UID
Every call is scoped to one project, which owns the audience, the segments and the senders the campaign will use.
#### 3. Export credentials
```bash
export INSTASENT_PROJECT="proj_xxx"
export INSTASENT_TOKEN="eyJhbGciOi..."
export BASE="https://api.instasent.com/v1/project/$INSTASENT_PROJECT"
```
The endpoint sits in the **medium** rate-limit class, and its ceiling scales with the organization's subscription plan — see [Rate limits](/platform-api/product-api/rate-limits). Campaign creation is a low-frequency operation by nature; if you are anywhere near the ceiling, something is looping.
## Your first draft
The smallest useful request is a channel, a title and some copy. Everything else has a default: the audience becomes the whole audience, the sender becomes the project's default for the channel, and the campaign sits on the calendar undated.
#### curl
```bash
curl -X POST "$BASE/campaign" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"channel": "sms",
"title": "Service notice",
"message": {
"text": "We are performing maintenance this Sunday. Service may be briefly unavailable."
}
}'
```
#### node
```js
const response = await fetch(`${BASE}/campaign`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.INSTASENT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: "sms",
title: "Service notice",
message: {
text: "We are performing maintenance this Sunday. Service may be briefly unavailable.",
},
}),
});
const { entity, metadata } = await response.json();
```
A fuller one — a segment, an explicit sender, a date and a tracked link:
```json
{
"channel": "sms",
"title": "Summer sale announcement",
"emoji": "☀️",
"date": "2026-06-15T09:30:00+02:00",
"sender": "67bdfa983114d0062d733795",
"compliance": "no-opt-out",
"audience": {
"include": ["vip-customers"],
"exclude": ["recently-messaged"]
},
"message": {
"text": "Summer sale starts today: 30% off everything.\nShop now: {{short:https://example.com/sale}}",
"allowUnicode": false
}
}
```
## The response
A successful call returns **`201 Created`** with the campaign under `entity` and, alongside it, anything the platform noticed while building the draft.
```json
{
"entity": {
"id": "68b0f2a4e5a6b7c8d9e0f1a2",
"title": "Summer sale announcement",
"status": "draft"
},
"metadata": {
"warnings": []
}
}
```
`entity` is the campaign as the campaign read endpoints return it — the example above is trimmed to the fields you are most likely to store. Keep the `id`: it is what the [campaign analytics](/platform-api/product-api/analytics/overview#campaign-report) endpoints take once the campaign has been sent.
### Warnings
`metadata.warnings` reports **non-fatal** findings. The draft was created; something in it is incomplete and a human has to close the gap before the campaign can go out.
| Warning | What it means | What to do |
| -------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
| `no-default-sender` | The project has no default sender for the channel, so the draft has none. | The campaign cannot be sent until a sender is chosen in the dashboard. Pass `sender` explicitly to avoid it. |
| `no-fallback-sender` | Same, for the SMS leg of an [RCS fallback](/platform-api/product-api/campaigns/message#falling-back-to-sms). | Pass `fallback.sender`, or configure an SMS sender on the RCS sender. |
> **Tip**: Treat a non-empty `warnings` array as a signal to surface in your own UI. A draft that arrives with `no-default-sender` looks complete and silently cannot be sent — telling the user at creation time is much cheaper than letting them discover it at the review step.
>
> These two warnings are also the best predictor you get of a later failure: a draft missing a sender is not only unsendable, it is **unestimable**, and the `409` it earns at [estimate](/platform-api/product-api/campaigns/estimating-and-deleting#estimating-a-draft) was already announced here in the `201`.
## The request body
Only `channel` and `title` are required. The shape is deliberately flat: `channel` decides which of the channel-specific message fields apply, and fields belonging to the other channel are ignored.
- `channel` — `string`, required
`sms` or `rcs`. Immutable once the draft exists — a campaign cannot change channel afterwards. See [Message content](/platform-api/product-api/campaigns/message).
- `title` — `string`, required
The campaign name shown in the dashboard, 3–64 characters. Internal: recipients never see it.
- `emoji` — `string`
An emoji for the campaign card in the dashboard, 1–2 characters.
- `description` — `string`
An internal note about the campaign, up to 1000 characters.
- `date` — `string`
Where the campaign sits on the dashboard calendar. Accepts a day, a day and time, or a full ISO 8601 datetime with an offset — **and nothing is scheduled by setting it**. See [Dates and scheduling](/platform-api/product-api/campaigns/scheduling).
- `time` — `string`
Time of day for the calendar anchor, `HH:MM`, for callers holding the day and the time apart. Rejected alongside a `date` that already carries a time. See [Dates and scheduling](/platform-api/product-api/campaigns/scheduling).
- `sender` — `string`, default: `the project's default for the channel`
Id of the sender to use. Must belong to this project. See [Choosing a sender](/platform-api/product-api/campaigns/message#choosing-a-sender).
- `compliance` — `string`, default: `the project's own policy`
Consent policy applied when the audience is resolved: `basic`, `no-opt-out` or `opt-in`. The legacy value `opt-out` is still accepted on input and normalized to `no-opt-out`, which is what reads return. See [Consent policy](/platform-api/product-api/campaigns/audience#consent-policy).
- `audience` — `object`, default: `everyone`
Who the campaign targets, as segment UIDs and inline filters. See [Audience targeting](/platform-api/product-api/campaigns/audience).
- `include` — `array`, default: `["_all"]`
Segments and/or filters to target.
- `exclude` — `array`
Segments and/or filters to remove from the target, applied after `include`.
- `message` — `object`
The campaign copy, in one language. Omit it to create an empty draft for someone to write in the dashboard. See [Message content](/platform-api/product-api/campaigns/message).
- `text` — `string`, required
The message body. Required whenever `message` is present.
- `language` — `string`
Two-letter lowercase code of the language this copy is written in. Required only with `translateTo`.
- `allowUnicode` — `boolean`
**SMS only.** Allow non-GSM characters. Ignored for RCS.
- `suggestions` — `array`
**RCS only.** Tappable buttons under the message, up to 4. Ignored for SMS.
- `fallback` — `object`
**RCS only.** An SMS for the contacts RCS cannot reach. See [Falling back to SMS](/platform-api/product-api/campaigns/message#falling-back-to-sms).
- `translateTo` — `array`
Other languages this campaign should eventually go out in, declared as empty. A paid feature. See [Declaring other languages](/platform-api/product-api/campaigns/message#declaring-other-languages).
## Limits
| Field | Limit |
| --------------------------- | --------------------------- |
| `title` | 3–64 characters |
| `emoji` | 1–2 characters |
| `description` | up to 1000 characters |
| `message.text` (RCS) | up to 3072 characters |
| `message.suggestions` | up to 4 buttons |
| `suggestions[].displayText` | up to 25 characters |
| `date` | less than one year from now |
| unsent drafts per project | 50 |
> **Note**: The 3-character minimum on `title` is the one that catches integrations out: a campaign named after a short code or a two-letter market (`ES`, `Q3`) is rejected. Give it a human title and keep your own reference in `description`.
An SMS body has no fixed maximum here — length drives cost instead. See [SMS](/platform-api/product-api/campaigns/message#sms) for how characters map to billed parts.
## Errors
Validation failures come back as **`422 Unprocessable Entity`** with a stable machine-readable code, so a caller can react to the reason rather than parse a sentence:
```json
{
"errors": {
"fields": {
"errorCode": ["unknown-segment"]
}
}
}
```
| `errorCode` | What happened | What to do |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unknown-channel` | `channel` is missing, or names a channel this endpoint cannot draft. | Send `sms` or `rcs`. |
| `channel-not-available` | The channel is not available on this project. | Check the project's channels before offering it as an option. |
| `unknown-segment` | A segment UID in `audience` does not exist in this project, or an audience list is not a list of UIDs and filters. | Read the project's segments and use their UIDs. See [Audience targeting](/platform-api/product-api/campaigns/audience). |
| `invalid-audience-filter` | An inline audience filter is not a valid filter, has no conditions, or names an unknown operator or attribute. | Fix the filter — the message names the offending part. See [Writing the filter](/platform-api/product-api/campaigns/audience#writing-the-filter). |
| `unknown-sender` | The `sender` id does not exist or belongs to another project. | Use a sender id from this project, or omit the field to take the default. |
| `fallback-not-supported` | A `fallback` was sent on a channel that reaches every contact. | Drop `fallback` on SMS campaigns. |
| `invalid-message` | The message could not be built: unknown consent policy, an RCS message with more than 4 buttons, a `fallback` with no copy, or `translateTo` without `message.language`. | See [Message content](/platform-api/product-api/campaigns/message). |
| `invalid-date` | The date or time was not understood, or a `time` was sent alongside a date that already carries one. | See [Dates and scheduling](/platform-api/product-api/campaigns/scheduling). |
Field-level validation errors — a `title` under 3 characters, a date beyond the one-year horizon — use the same `422` envelope keyed by the field name instead of `errorCode`:
```json
{
"errors": {
"fields": {
"title": ["This value is too short."]
}
}
}
```
Two conditions produce a **`409 Conflict`**, and in both cases nothing is created. Multi-language campaigns are a paid feature, so a request that declares languages without the subscription to match is refused. And a project that already holds **50 campaigns sitting in `draft`** takes no more: the ceiling is checked before any other work, and it exists so an automated caller cannot fill a project with drafts in a loop. Only the raw `draft` status counts towards it, so a draft that has been estimated no longer does. Everything else — `401`, `403`, `429`, `5xx` — behaves as described in [Errors](/platform-api/product-api/errors).
## What's next
- [Audience targeting](/platform-api/product-api/campaigns/audience) - Segments, inline filters and how to count the reach before you create anything.
- [Message content](/platform-api/product-api/campaigns/message) - Copy, template variables, senders, RCS buttons, SMS fallback and languages.
- [Estimating and deleting](/platform-api/product-api/campaigns/estimating-and-deleting) - What you can do to the draft once it exists: price it, or remove it.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/audience
# Audience targeting
Point a campaign draft at the right contacts: segment UIDs, inline audience filters, exclusions and the consent policy. Includes the filter grammar the audience field expects and how to count the reach before creating anything.
**Language:** en
**Audience:** developer
**TLDR:** audience.include and audience.exclude take segment UIDs (strings) and inline Audience Query Filters (objects), mixed freely; include defaults to ["_all"], the whole audience. An inline filter needs a root group with at least one condition, uses named operators and values as an ARRAY — there is no eq operator. Count the reach first with POST /audience/count, adding filterCompliance (basic, opt-out, opt-in) to count under the policy the campaign will carry.
**Search keywords:** audience, targeting, segment, segments, recipients, who receives, filter, custom audience, exclude, suppression, consent policy, compliance, filterCompliance, opt-in, no-opt-out, no opt-out, opt-out, basic, count contacts, audience count, reach, default policy
**Related pages:** /platform-api/product-api/campaigns/creating-a-draft, /platform-api/product-api/campaigns/overview, /platform-api/product-api/audience/query-filter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/audience/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/audience.md (Markdown)
The `audience` object decides who a campaign reaches. It holds two lists — `include` and `exclude` — and both accept the same two kinds of entry: a **segment UID**, or an **inline filter** written in the same grammar the segment endpoints return. You can mix them in one list, which is what makes it possible to say "everyone in the VIP segment plus everyone in Spain with a mobile number, minus the people we messaged this week" in a single call.
## Two kinds of entry
- **A segment UID** — a string. Targets an existing segment by identity: the campaign keeps the link, so the dashboard shows the audience under the segment's own name (*VIP customers*), not as an anonymous filter. The special UID `_all` is the whole audience.
- **An inline filter** — an object. A filter sent with the request, stored on the campaign as a **custom audience** labelled by its position, exactly as if it had been built with the filter builder in the panel.
Prefer a segment UID whenever the audience already exists as a segment: it keeps the campaign readable in the dashboard, and a marketer opening the draft recognises what they are looking at. Reach for an inline filter when the criteria are computed by your own system and would not be worth persisting as a segment.
```json
{
"audience": {
"include": ["vip-customers"],
"exclude": ["recently-messaged"]
}
}
```
## Defaults, and how the two lists combine
- Omit `audience` entirely, or send an empty `include`, and the campaign targets **everyone**: `include` defaults to `["_all"]`.
- A contact matching **any** entry of `include` is in.
- `exclude` is applied **afterwards**: a contact matching any entry of it is removed, whichever include put them there.
- Repeated segment UIDs within a list are collapsed, so building a list from several code paths cannot double-count.
> **Note**: To target the whole audience, use the `_all` segment rather than a filter with no conditions. An empty filter is rejected — see [Inline filters](#inline-filters-custom-audiences) below.
## Targeting segments
Segment UIDs are the slugs the segment endpoints return, not database ids. List them first if you are building a picker:
```bash
curl "$BASE/segment" \
-H "Authorization: Bearer $INSTASENT_TOKEN"
```
Then target one, several, or all of them:
```json
{
"audience": {
"include": ["vip-customers", "newsletter-subscribers"],
"exclude": ["employees"]
}
}
```
A UID that does not exist in the project is refused with `unknown-segment` rather than quietly resolving to nobody — a typo in a segment name would otherwise produce a campaign that looks fine and reaches no one.
## Inline filters (custom audiences)
An inline filter is an object with a `root` group. Send it in place of a UID:
```json
{
"audience": {
"include": [
{
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_country_code",
"operator": "matches-string",
"values": ["ES"]
}
]
}
}
]
}
}
```
Three rules govern them:
- **At least one condition.** A root group with no children would be stored as a "custom audience" that silently matches everyone. Targeting everyone is legitimate; doing it by accident under a label that says otherwise is not, so an empty filter is rejected with `invalid-audience-filter`.
- **Validated before the draft is stored.** Both the structure and the contents are checked up front: an unknown operator or an attribute that does not exist in the project is refused at creation, with the reason in the message, instead of failing later when someone opens the draft and the audience is first counted.
- **No segment identity.** A filter you supply is a custom audience by definition; any segment metadata sent along with it is discarded.
## Writing the filter
The grammar is the **Audience Query Filter (AQF)**, the same one behind `/audience/search` and every dynamic segment. The [full reference](/platform-api/product-api/audience/query-filter) documents every condition type and operator; what follows is the part that matters when you are writing one by hand for a campaign.
A filter is a `root` group holding `children`, combined with AND unless the group says otherwise:
```json
{
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "attribute_condition",
"key": "_country_code",
"operator": "matches-string",
"values": ["ES"]
},
{
"type": "attribute_condition",
"key": "_phone_mobile",
"operator": "exists",
"values": []
}
]
}
}
```
> **Warning**: **Operators are named, and `values` is always an array.** There is no `eq`, no `=` and no singular `value` field. The equality operator is `matches-string` (or `matches-number` for numbers), and its argument goes in `values` as a list — `"values": ["ES"]`, never `"value": "ES"`. This is the single most common mistake when writing a filter by hand.
The operators you will reach for most in a campaign audience:
| Operator | Matches | `values` |
| -------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- |
| `matches-string` | Exact value, case-sensitive | `["ES"]` |
| `matches-number` | Exact number | `[3]` |
| `contains` / `startswith` / `endswith` | Substring, prefix, suffix — case-insensitive | `["gmail"]` |
| `exists` / `exists-not` | The attribute is present / absent | `[]` |
| `matches-bool` | Boolean equality | `[true]` |
| `range-number` | Numeric range | `{ "lowerNumber": 100, "upperNumber": null }` |
| `range-date` | Absolute date range | `{ "lowerDate": "2026-01-01", "upperDate": "2026-03-31" }` |
| `range-date-relative` | Range relative to now | `{ "lowerOffset": -30, "lowerOffsetPeriod": "day" }` |
| `in-segment` / `in-segment-not` | Segment membership, on a `segment_condition` | ignored — the UID goes in `key` |
Every operator with a `-not` suffix inverts its positive counterpart. Conditions can also target events (`group_event` + `event_condition`) — "bought in the last 30 days" is an event condition, not an attribute one. The [AQF reference](/platform-api/product-api/audience/query-filter) has the exact shapes.
> **Tip**: **The shortcut: read a segment back and adapt it.** `GET /v1/project/{project}/segment/{uid}` returns a segment's own filter, under `queryFilter`, in exactly the shape this field expects. Build the audience once in the dashboard's filter builder, read it, and use it as the template for the filters your integration generates. Faster than writing one from scratch, and guaranteed to be valid.
## Mixing segments and filters
Both lists are heterogeneous, so a segment and a filter can sit side by side. This targets a loyalty segment plus every Spanish contact with a mobile number, minus the people already messaged this week:
```json
{
"channel": "sms",
"title": "Flash sale for Spanish contacts",
"audience": {
"include": [
"loyalty-members",
{
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_country_code",
"operator": "matches-string",
"values": ["ES"]
},
{
"type": "attribute_condition",
"key": "_phone_mobile",
"operator": "exists",
"values": []
}
]
}
}
],
"exclude": ["recently-messaged"]
},
"message": {
"text": "Solo hoy: 30% de descuento en toda la tienda.",
"allowUnicode": true
}
}
```
In the dashboard this draft shows two included audiences — the segment under its name, the filter as a custom audience — and one exclusion.
## Consent policy
`compliance` decides which contacts the audience is allowed to reach once consent is taken into account. It is a top-level field, not part of `audience`, and defaults to the project's own policy when omitted.
| Value | Reaches |
| ------------ | -------------------------------------------------------------------- |
| `basic` | Every active contact, ignoring marketing preferences. Maximum reach. |
| `no-opt-out` | Everyone except contacts who opted out of this channel. |
| `opt-in` | Only contacts with explicit consent. |
The three policies also differ in what happens when a recipient opts out, which is a product decision rather than an API one — the full model is in [Consent policies](/platform/en/campaigns/compliance-policies).
`no-opt-out` was previously called `opt-out`. The API still accepts `opt-out` on input indefinitely and normalizes it, so an existing integration keeps working; reads always return `no-opt-out`. The dashboard labels the policy **No opt-out**.
The project's own policy is readable before you draft anything: `GET /project/{project}` returns it as `generalConfig.channelSms.defaultCompliancePolicy` (and `channelRcs`, `channelWhatsapp`, with `channelDefaults` covering the channels that set none). It is always resolved, never `null`. When `compliance` is omitted the draft takes that value and stores it on the campaign, so the campaign carries its policy explicitly rather than inheriting it later.
The same three policies are available as a filter, which is how you count the audience under a policy before creating anything: see [Counting who can receive](/platform-api/product-api/audience/query-filter#counting-who-can-receive-filtercompliance).
## Count the reach before you create
The audience of a campaign is only known once it is resolved, and a draft is a poor place to discover that a filter matches eleven people. Count first:
[`POST /v1/project/{project}/audience/count` - Count the audience contacts matching a filter.](/platform-api/product-api/reference)
```bash
curl -X POST "$BASE/audience/count" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"version": "0.0.1",
"root": {
"type": "group",
"children": [
{
"type": "attribute_condition",
"key": "_country_code",
"operator": "matches-string",
"values": ["ES"]
}
]
},
"filterCompliance": { "sms": "no-opt-out" }
}'
```
It takes the same filter shape as an inline audience entry, so you can count exactly what you are about to send, and it is a cheap read compared to creating and deleting drafts.
`filterCompliance` is what makes the total a *sending* figure: name the channel the campaign will use and the policy it will carry (the one you are about to send as `compliance`, or the project's own if you omit it) and the count applies the same consent and reach rules the campaign applies, so it matches the audience figure the dashboard shows for that draft. Drop the key and you get the raw audience instead: every contact matching the conditions, including those with no mobile number and those the policy would exclude. The key, its policies and its channels are documented in [Counting who can receive](/platform-api/product-api/audience/query-filter#counting-who-can-receive-filtercompliance).
> **Note**: One thing the count does not know is which countries the campaign's sender covers, so a campaign can deliver to fewer contacts than the policy count when the sender does not reach every country in the audience. Let the dashboard's own estimate, computed against the chosen channel and sender, be the figure a human confirms.
## Errors
| `errorCode` | Cause |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `unknown-segment` | A UID does not exist in this project, or `include`/`exclude` is not a list of UIDs and filters. |
| `invalid-audience-filter` | The filter is not valid AQF, has no conditions, or names an operator or attribute that does not exist. The message names the offending part. |
## What's next
- [Audience query filter](/platform-api/product-api/audience/query-filter) - The full grammar: every condition type, operator and value shape.
- [Message content](/platform-api/product-api/campaigns/message) - What the audience you just defined is going to receive.
- [Creating a draft](/platform-api/product-api/campaigns/creating-a-draft) - Back to the endpoint contract, limits and error codes.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/message
# Message content
Write the copy a campaign draft carries: template variables and short links, the sender that speaks for it, SMS Unicode, RCS buttons, the SMS fallback for contacts RCS cannot reach, and how to declare languages to translate later.
**Language:** en
**Audience:** developer
**TLDR:** message.text is the copy and is required whenever message is sent; allowUnicode is SMS-only and suggestions (max 4, displayText max 25) are RCS-only. Use {{short:url}} in the text for tracked short links, but never around a suggestion URL — those are shortened by the platform. fallback is RCS-only and its text is required. translateTo declares empty languages, needs message.language, and is a paid feature.
**Search keywords:** message, text, copy, template, short link, shortener, unsubscribe, sender, unicode, GSM, RCS, suggestions, buttons, quick reply, fallback, translation, multi-language, translateTo
**Related pages:** /platform-api/product-api/campaigns/creating-a-draft, /platform-api/product-api/campaigns/overview, /platform-api/product-api/campaigns/audience
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/message/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/message.md (Markdown)
`channel` decides the shape of everything on this page. A campaign is `sms` or `rcs`, the choice is made at creation and cannot change afterwards, and each channel accepts fields the other ignores: `allowUnicode` is meaningless for RCS, `suggestions` is meaningless for SMS, and only a channel that cannot reach every contact takes a `fallback`.
What both share is the core: one block of copy, in one language, and a sender to speak for it.
## One block of copy
```json
{
"message": {
"text": "Your order is ready for pickup at our downtown store."
}
}
```
`message.text` is required whenever you send a `message` at all. You can also **omit `message` entirely** — the result is a valid draft with an audience, a sender and a date, and no copy, for someone to write in the dashboard. That is a legitimate way to use this endpoint: prepare the boring parts programmatically and leave the words to a human.
What this endpoint never does is invent copy. Nothing is generated, completed or translated on your behalf; a draft contains exactly the text you supplied.
## Template variables
The text supports the same template variables as the rest of the platform. Two of them matter for almost every campaign:
| Variable | Effect |
| ------------------------------------ | ----------------------------------- |
| `{{short:https://example.com/page}}` | Replaces the URL with a short link. |
| `{{unsubscribe}}` | Inserts an unsubscribe link. |
A short link costs 10–15 characters instead of the full URL, and every tap on it is measurable in the campaign's reporting. In SMS, where length is billed, that is usually worth doing for every link you include.
```json
{
"message": {
"text": "Summer sale starts today: 30% off everything.\nShop now: {{short:https://example.com/sale}}"
}
}
```
> **Warning**: **Do not wrap a suggestion URL in `{{short:...}}`.** The rule applies to the message *text*. URLs inside RCS `suggestions` are shortened and tracked by the platform on their own when the message is prepared — wrapping them yourself shortens an already-shortened link.
## Choosing a sender
`sender` is a top-level field, not part of `message`, and it takes the id of a sender that belongs to this project. An id from another project is refused with `unknown-sender` rather than silently ignored.
Omit it and the project's **default sender for the channel** applies. If the project has no default either, the draft is still created — with no sender, and a `no-default-sender` entry in `metadata.warnings`. The campaign cannot be sent until someone picks one in the dashboard, which is why that warning is worth surfacing in your own UI rather than dropping.
Whichever sender ends up on the campaign is also inherited by every language declared through `translateTo` that does not name its own.
## SMS
An SMS body has no hard maximum in this API; length maps to cost instead.
- `allowUnicode` — `boolean`, default: `false`
**SMS only.** Set it to `true` when the text may contain non-GSM-7 characters — accents, emoji, most non-Latin scripts. Ignored on RCS campaigns.
A GSM-7 part holds 160 characters; a Unicode part holds 70. Longer copy is split into several parts, and each part is billed as a message — so a 200-character GSM-7 message costs two, and the same text carrying one emoji costs three. That is simply the price of the copy you chose; a `{{short:...}}` link is the cheapest way to claw characters back.
```json
{
"channel": "sms",
"title": "Flash sale for Spanish contacts",
"message": {
"text": "Solo hoy: 30% de descuento en toda la tienda.",
"allowUnicode": true
}
}
```
## RCS
An RCS campaign carries a text bubble of up to **3072 characters** and, optionally, up to **4** tappable buttons under it. The richer RCS formats — cards, carousels, media — are not surfaced on this endpoint yet; a draft that needs them is finished in the dashboard.
- `suggestions` — `array`
**RCS only.** The buttons shown under the message, in order, up to 4.
- `type` — `string`, required
`url` opens a link; `dialer` starts a phone call.
- `displayText` — `string`, required
The label the recipient sees, up to 25 characters.
- `url` — `string`
Required when `type` is `url`. A plain absolute URL — not wrapped in a template variable.
- `phoneNumber` — `string`
Required when `type` is `dialer`. The number to call, in E.164 format.
```json
{
"channel": "rcs",
"title": "Order pickup reminder",
"audience": {
"include": ["pending-pickups"]
},
"message": {
"text": "Your order is ready for pickup at our downtown store.",
"suggestions": [
{
"type": "url",
"displayText": "View order",
"url": "https://example.com/orders"
},
{
"type": "dialer",
"displayText": "Call the store",
"phoneNumber": "+34600000000"
}
]
}
}
```
Two behaviours to know when you generate suggestions programmatically:
- **Incomplete or unrecognised entries are dropped**, not fatal. A `url` button with no `url`, or a type this endpoint does not support, is left out and the rest of the draft is created. Count the buttons on the response if your caller needs to know what survived.
- **More than four accepted buttons is an error** (`invalid-message`). The cap is a channel limit, not a preference, so it fails loudly rather than truncating your list at an arbitrary point.
## Falling back to SMS
RCS does not reach every contact: the handset and the network have to support it. A **fallback** covers the rest with an SMS, forming an `rcs -> sms` chain — the contacts RCS can reach get the rich message, everyone else gets the SMS.
- `fallback` — `object`
**RCS only.** Sending it on an SMS campaign is refused with `fallback-not-supported`: SMS already reaches everyone, so there is nothing to fall back to.
- `text` — `string`, required
The SMS body. Required whenever `fallback` is present.
- `sender` — `string`
Id of the SMS sender for this leg. Must belong to the project.
- `allowUnicode` — `boolean`
Allow non-GSM characters in the fallback SMS.
```json
{
"channel": "rcs",
"title": "Order pickup reminder",
"audience": {
"include": ["pending-pickups"]
},
"message": {
"text": "Your order is ready for pickup at our downtown store.",
"suggestions": [
{
"type": "url",
"displayText": "View order",
"url": "https://example.com/orders"
}
]
},
"fallback": {
"text": "Your order is ready for pickup at our downtown store: {{short:https://example.com/orders}}"
}
}
```
**The fallback copy is required and is never derived from the RCS body.** Beyond the standing rule that this endpoint writes no copy nobody supplied, there is a cost reason: an RCS body may run to 3072 characters, and pushing that into SMS would bill a long multi-part message nobody asked to send. Write the SMS version deliberately, short, with a short link.
### Which sender the fallback uses
The SMS leg resolves its sender from the most specific answer available:
#### 1. The explicit one
`fallback.sender`, when you send it.
#### 2. The one the RCS sender designates
The SMS sender configured on the RCS sender itself — whoever set up the RCS sender already chose which SMS speaks for it.
#### 3. The project default
The project's default SMS sender.
If none of the three exists the draft is still created, with `no-fallback-sender` in `metadata.warnings`.
## Declaring other languages
`translateTo` lists the other languages the campaign should eventually go out in, as two-letter lowercase codes:
```json
{
"channel": "sms",
"title": "Product launch",
"audience": {
"include": ["newsletter-subscribers"]
},
"message": {
"language": "en",
"text": "Our new collection is live. Take a look: {{short:https://example.com/new}}"
},
"translateTo": ["fr", "de"]
}
```
Each entry becomes a **declared but empty** language on the draft — a recorded intention to translate, not a translation. No copy is generated. Someone writes the French and the German in the dashboard, and until they do, **the campaign cannot be estimated, quoted or prepared**: an empty declared language is a deliberate gate, so a half-translated campaign can never be sent by accident.
`message.language` is the two-letter code of the copy you already wrote. Its only job is this de-duplication: without it, the language you supplied would be declared as one of the missing translations and the campaign would wait forever on a translation that already exists. That is why it is **optional on its own and required whenever `translateTo` is used** — and why it is not stored anywhere: it is a fact about your request, not about the campaign.
> **Warning**: Multi-language campaigns are a **paid feature**. A request that declares languages without a subscription that includes it returns `409 Conflict` and creates nothing at all — there is no partial draft to clean up.
## Errors
| `errorCode` | Cause |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid-message` | `message` was sent without `text`; a `fallback` was sent without `text`; more than 4 RCS buttons; `translateTo` without `message.language`; an unknown `compliance` value. |
| `unknown-sender` | The sender id does not exist or belongs to another project. |
| `fallback-not-supported` | A `fallback` was sent on a channel that reaches every contact. |
| `unknown-channel` | `channel` is missing, or names a channel this endpoint cannot draft. |
## What's next
- [Dates and scheduling](/platform-api/product-api/campaigns/scheduling) - Where the campaign sits on the calendar — and why nothing is scheduled here.
- [Audience targeting](/platform-api/product-api/campaigns/audience) - Who receives what you just wrote.
- [Creating a draft](/platform-api/product-api/campaigns/creating-a-draft) - Back to the endpoint contract, limits and error codes.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/scheduling
# Dates and scheduling
The date on a campaign draft is a calendar anchor, not a send time — nothing is scheduled from the API. This page covers the three accepted date forms, which timezone each one is read in, the separate time field, and why unparseable input is refused rather than guessed at.
**Language:** en
**Audience:** developer
**TLDR:** date places the draft on the dashboard calendar and schedules nothing. It accepts an ISO 8601 datetime with an offset (honoured as an exact instant), a date and time without an offset (read in the project's timezone), or a bare day (project timezone, 19:00). Integrations should send the offset form. time (HH:MM) is an alternative to putting the hour in date; sending both is an error. Maximum one year ahead.
**Search keywords:** date, schedule, scheduling, send time, when, timezone, time zone, offset, ISO 8601, UTC, calendar, plan a campaign, future campaign
**Related pages:** /platform-api/product-api/campaigns/creating-a-draft, /platform-api/product-api/campaigns/estimating-and-deleting, /platform/en/campaigns/creating-a-campaign
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/scheduling/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/scheduling.md (Markdown)
`date` places a campaign draft on the dashboard calendar so a marketer opening the project can see what is coming and when it was meant to go out. It is a **planning anchor**, and setting it arms nothing.
> **Warning**: **Nothing is scheduled by this endpoint, at any precision.** A date to the minute, with a timezone offset, still produces a draft that will not send. Arming a campaign happens in the dashboard, at the review step, after a human has seen the estimate. There is no API field that skips it.
>
> That estimate can now be [started from the API](/platform-api/product-api/campaigns/estimating-and-deleting#estimating-a-draft), which changes nothing here: knowing what a campaign would cost is not a step towards sending it, and the confirmation at the review step is still a person's.
`date` is also entirely optional: omit it and the draft simply carries no date, which is the right choice when your integration knows what to say but not yet when.
## The three accepted forms
The precision you send is the precision that is stored — and **the input decides which timezone applies**:
| Form | Example | Interpreted in |
| ------------------------ | ---------------------------------------------------------------------------- | ------------------------------------------------------------- |
| ISO 8601 with an offset | `2026-06-15T09:30:00+02:00`, `2026-06-15T09:30:00Z`, `2026-06-15T09:30+0200` | the offset you sent — the project's timezone is not consulted |
| Date and time, no offset | `2026-06-15T09:30`, `2026-06-15 09:30` | the project's timezone |
| Day only | `2026-06-15` | the project's timezone, at 19:00 |
The project's timezone falls back to the organization's when the project does not set one of its own.
### Integrations should send the offset form
```json
{
"date": "2026-06-15T09:30:00+02:00"
}
```
It is the only form that means the same instant on both ends. A server in another region, a container running in UTC, a laptop on summer time — none of them change what that string denotes. The two offset-less forms are a convenience for a human typing a date into your UI, where "half past nine" naturally means half past nine where the audience lives; they are the wrong choice for a machine that already knows the exact moment.
## Sending the time separately
Some callers hold the day and the time apart — a date picker and a time picker, two columns in a spreadsheet — and joining them into an ISO string is busywork. `time` exists for exactly that:
```json
{
"date": "2026-06-15",
"time": "09:30"
}
```
`time` is `HH:MM`, read in the project's timezone, and it replaces the 19:00 default a bare day would otherwise get.
> **Warning**: **Sending `time` alongside a `date` that already carries an hour is an error**, not a precedence rule. `{"date": "2026-06-15T09:30", "time": "11:00"}` is refused with `invalid-date`. Two answers to the same question is how an integration ends up anchored at the wrong hour and never finds out why — so the API asks you to pick one instead of quietly choosing for you.
## Parsing is strict on purpose
The accepted forms above are an allow-list. Anything else is refused with `invalid-date` rather than interpreted:
| Input | Why it is refused |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `15/06/2026` | Ambiguous. Depending on the separator and locale it reads as day 15 or month 15 — an API that silently picks one is worse than one that says no. |
| `tomorrow`, `+1 week` | Relative expressions are not dates. Resolve them on your side, where you know the user's timezone. |
| `2026-13-45` | Out of range. It is not rolled over into February of the following year. |
| `2026-06-15T25:99` | Out of range. It is not rolled over into the next day. |
The overflow cases are the ones worth designing around: a caller that computed a date wrongly gets told, at the moment of the call, instead of finding a campaign anchored on a day nobody chose.
## The one-year horizon
A campaign date must be **less than a year from now**. A date beyond that is understood perfectly well and then refused by the campaign itself, so it comes back as a field-level `422` keyed by the campaign's own field name (`campaignAt`) rather than as an `invalid-date` code:
```json
{
"errors": {
"fields": {
"campaignAt": ["This value should be less than Jun 15, 2027, 12:00 AM."]
}
}
}
```
## Scheduling the actual send
The send time is chosen in the dashboard, on the campaign's **Review & schedule** step, along with the delivery settings that surround it. The reader-facing walkthrough is [Creating a campaign](/platform/en/campaigns/creating-a-campaign); [Delivery settings](/platform/en/campaigns/delivery-settings) covers the options that shape how the send runs once it is armed.
For an integration this means the handover is clean: you supply the intent — who, what, and the day it is meant for — and a person supplies the confirmation.
## What's next
- [Creating a draft](/platform-api/product-api/campaigns/creating-a-draft) - The endpoint contract, the full request body, limits and error codes.
- [Audience targeting](/platform-api/product-api/campaigns/audience) - Segments, inline filters and counting the reach up front.
- [Message content](/platform-api/product-api/campaigns/message) - Copy, senders, RCS buttons, SMS fallback and languages.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/reading-campaigns
# Reading campaigns
The three read endpoints: one campaign by id, the project's filtered campaign list, and a digest of the whole project's campaign activity. This page also covers the fields a campaign carries and how to collect the result of an asynchronous estimate or delete.
**Language:** en
**Audience:** developer
**TLDR:** GET /campaign/{id} returns the campaign under entity and is how you collect the result of an estimate (the counts, preparedUnits and the prices) and how you tell a finished delete from one still cleaning up. GET /campaign lists them under entities with Query Filter syntax, sorted by campaignAt desc, and hides direct sends. GET /campaign/summary is a project-wide digest: counts by phase and status, the last campaign sent with its figures, the next one scheduled, and the 30/90-day cadence, built from aggregates rather than a paged listing.
**Search keywords:** read campaign, get campaign, campaign status, check campaign, list campaigns, campaign list, campaign summary, digest, dashboard, overview, poll, polling, campaign fields, contactsCount, preparedUnits, priceUser, byPhase, PROJECT_CAMPAIGN_READ
**Related pages:** /platform-api/product-api/campaigns/overview, /platform-api/product-api/campaigns/estimating-and-deleting, /platform-api/further-reading/query-filter
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/reading-campaigns/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/reading-campaigns.md (Markdown)
Reading is not only how you look a campaign up. It is the step that **closes the two asynchronous operations**: an estimate answers `202` and a delete answers `204`, and neither carries the outcome in its own response. Reading the campaign back is where you find it.
All three endpoints take the `PROJECT_CAMPAIGN_READ` scope and sit in the **large** rate-limit class.
## Reading one campaign
[`GET /v1/project/{project}/campaign/{id}` - View a campaign.](/platform-api/product-api/reference)
The campaign comes back under `entity`, in the same shape the creation endpoint returns.
```json
{
"entity": {
"id": "68b0f2a4e5a6b7c8d9e0f1a2",
"title": "Summer sale announcement",
"status": "estimated",
"channelType": "sms",
"purpose": "standard",
"audienceContactsCount": 18400,
"channelContactsCount": 17250,
"contactsCount": 16980,
"ignoredContactsCount": 270,
"preparedUnits": 20376,
"priceUser": { "value": 611.28, "currency": "EUR" },
"campaignAt": "2026-06-15T09:30:00+02:00",
"scheduledAt": null,
"createdAt": "2026-06-01T11:04:22+00:00"
}
}
```
The entity carries more than the example shows. The fields worth knowing by name group into four families:
| Family | Fields |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| What it is | `id`, `title`, `emoji`, `description`, `favorite`, and the channel as a pair: `channelType` is the string (`sms` or `rcs`) and `channel` is the channel's own object, whose shape follows from it. `purpose` marks what kind of campaign this is; one you created through the API is always `standard`. |
| Where it is | `status`, the raw status every operation in this section is gated by. See [Statuses and phases](/platform-api/product-api/campaigns/overview#statuses-and-phases). |
| Who it reaches | `audienceContactsCount`, `channelContactsCount`, `contactsCount` and `ignoredContactsCount`, explained below. The targeting itself reads back as `includeQuerySegments` and `includeQueryFilters`, with their `exclude` counterparts. |
| What it costs | `preparedUnits` is the billable message parts, with `unitsMin` and `unitsMax` bracketing it. `priceUser` is the cost applied to the account, with `priceMin` and `priceMax` around it. Every price is an object: `{ "value": 611.28, "currency": "EUR" }`. |
| When it happens | `campaignAt` is the calendar anchor, `scheduledAt` the armed send time (`null` until a person confirms it), and `preparedAt`, `confirmedAt`, `canceledAt`, `createdAt` and `updatedAt` timestamp the rest. `localTime` and `localScheduledAt` give the same instants in the project's timezone. |
**The four counts are easy to confuse**, and they answer different questions. `audienceContactsCount` is who the targeting resolves to, ignoring the channel. `channelContactsCount` narrows that to the contacts the channel can actually reach. `contactsCount` is what the estimate or the preparation actually resolved, and `ignoredContactsCount` is what it dropped on the way. A gap between the first two is a reachability problem; a gap between the last two is a consent or data problem.
Once a campaign has been sent it also carries `stats`, `channelBreakdown` and `statsAt`. Those are the summary figures; the full reporting surface is [campaign analytics](/platform-api/product-api/analytics/overview#campaign-report).
> **Note**: **A campaign in `estimating` reads as empty, and that is correct.** Starting an estimate clears the counts and the prices before the worker runs, so between the `202` and the worker landing you will read zeros and nulls. It is not a failed estimate. Wait for `status` to leave `estimating` before you believe the figures.
## Listing campaigns
[`GET /v1/project/{project}/campaign` - List the project's campaigns.](/platform-api/product-api/reference)
The list takes the shared [Query Filter](/platform-api/further-reading/query-filter) syntax for filtering, sorting and pagination, so `status_eq=sent` or `campaignAt_gte=2026-01-01T00:00:00+00:00` work here as they do on every other list endpoint. Campaigns come back under `entities`, with the window described under `metadata`:
```json
{
"entities": [{ "id": "68b0f2a4e5a6b7c8d9e0f1a2", "title": "Summer sale announcement", "status": "sent" }],
"metadata": { "count": 47, "start": 0, "limit": 50 }
}
```
Two behaviours are specific to this list:
- **It is sorted newest first by calendar date.** With no `_sort`, the default is `campaignAt:desc`.
- **`_limit` on its own is ignored.** Pagination needs both halves: send `_start=0&_limit=50`.
### What the list leaves out
The list only returns campaigns a marketer would recognise as campaigns. One-off direct sends are recorded as campaigns internally, carry `purpose: direct` rather than `standard`, and are **filtered out here unconditionally**. There is no parameter that brings them back, and a filter of your own on `purpose` cannot widen it.
This matters if you are reconciling volumes: the campaign list is not the record of everything the project sent. It is the record of everything the project *campaigned*.
## The project digest
[`GET /v1/project/{project}/campaign/summary` - A digest of the project's campaign activity.](/platform-api/product-api/reference)
One call that answers "what has this project been doing", without paging the list. It is built from aggregates over indexed queries and two point reads, so it stays cheap on projects with thousands of campaigns, and it is the natural thing to put behind a dashboard or hand to an assistant opening a conversation.
```json
{
"entity": {
"total": 47,
"byPhase": { "draft": 3, "preparing": 1, "scheduled": 2, "sending": 0, "sent": 39, "stopped": 2 },
"byStatus": { "draft": 3, "prepared": 1, "scheduled": 2, "sent": 39, "canceled": 2 },
"lastSent": {
"id": "68b0f2a4e5a6b7c8d9e0f1a2",
"title": "Summer sale announcement",
"channel": "sms",
"sentAt": "2026-06-15T09:30:00+00:00",
"sentAgo": "6 days ago",
"recipients": 16980,
"sent": 16980,
"delivered": 16659,
"deliveryRate": 0.9812,
"clicks": 2143,
"cost": 611.28,
"statsAt": "2026-06-16T03:00:00+00:00"
},
"nextScheduled": {
"id": "68b0f2a4e5a6b7c8d9e0f1b7",
"title": "Restock notice",
"channel": "rcs",
"scheduledAt": "2026-06-22T10:00:00+00:00",
"inHours": 18.5,
"recipients": 4210
},
"cadence": { "sentLast30Days": 4, "sentLast90Days": 11 }
}
}
```
| Field | What it holds |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `total` | Every campaign in the project. |
| `byPhase` | Counts in the six [phases](/platform-api/product-api/campaigns/overview#statuses-and-phases): `draft`, `preparing`, `scheduled`, `sending`, `sent`, `stopped`. |
| `byStatus` | The same campaigns counted by raw status, for when the six buckets are too coarse. |
| `lastSent` | The most recent campaign that went out, with its headline figures. `null` if none ever did. |
| `nextScheduled` | The next campaign due out, with `inHours` until it goes. `null` if nothing is armed. |
| `cadence` | How many campaigns went out in the last 30 and 90 days. |
Two conveniences are worth noticing, because they save a round of arithmetic in a UI: `deliveryRate` arrives precomputed as a fraction between 0 and 1, and `sentAgo` arrives already in words ("6 days ago").
> **Tip**: **Do not rebuild this from the list.** A count by phase taken from page one of the campaign list is simply wrong from page two onwards, and paging a project's whole history to add up six numbers is expensive for an answer this endpoint already has.
## Collecting an asynchronous result
Both write operations on an existing campaign finish somewhere else. The pattern is the same for each: call, then re-read until the campaign tells you it is done.
#### 1. After an estimate
The `202` leaves the campaign in `estimating`. Re-read it until `status` changes, then take `contactsCount`, `ignoredContactsCount`, `preparedUnits` and the price fields. The worker typically takes about a minute, so poll on the order of seconds, not milliseconds.
#### 2. After a delete
The `204` has no body and does not always mean the campaign is gone. Re-read it: a **`404`** means it is gone, and the campaign still there in `deleted` means a worker is removing its messages and will finish on its own. Neither is an error, and an integration that treats a post-delete `404` as a failure will report problems that did not happen.
## What's next
- [Estimating and deleting](/platform-api/product-api/campaigns/estimating-and-deleting) - The two operations whose results you collect here, and the states each one accepts.
- [Query Filter](/platform-api/further-reading/query-filter) - The filtering, sorting and pagination syntax the campaign list shares with every other list endpoint.
- [Campaign analytics](/platform-api/product-api/analytics/overview) - The full reporting surface for a campaign that has been sent.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/estimating-and-deleting
# Estimating and deleting
The two operations that act on a campaign which already exists: pricing a draft and removing one. Both are asynchronous, both are gated by the campaign's status, and both are learned about by reading the campaign back rather than from their own response.
**Language:** en
**Audience:** developer
**TLDR:** PATCH .../estimate returns 202 with the campaign in estimating and a worker fills the figures in about a minute later, so you re-read to collect them. It is a WRITE: on a prepared campaign it discards the prepared messages, and the 202 itself already clears the counts and prices. DELETE returns 204, is hard and has no undo, and for a prepared or scheduled campaign it goes through deleted first while a worker removes its messages, so a post-delete campaign still listed in deleted is cleanup in progress, not a failure.
**Search keywords:** estimate campaign, campaign estimate, campaign cost, how much will it cost, price a campaign, precision, delete campaign, remove campaign, cancel campaign, undo campaign, 409, 204, PROJECT_CAMPAIGN_WRITE
**Related pages:** /platform-api/product-api/campaigns/overview, /platform-api/product-api/campaigns/reading-campaigns, /platform-api/product-api/campaigns/creating-a-draft
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/estimating-and-deleting/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/campaigns/estimating-and-deleting.md (Markdown)
Two operations act on a campaign that already exists. **Estimate** prices it — how many contacts it would reach, how many message parts that becomes, what it would cost. **Delete** removes it. Both take the same `PROJECT_CAMPAIGN_WRITE` scope as creation, and both sit in the **restrictive** rate-limit class.
They share one property worth understanding before you call either: **neither has finished the work by the time it answers you.** Each hands back a status and leaves a worker to do the rest, so in both cases the way you learn the outcome is to [read the campaign again](/platform-api/product-api/campaigns/reading-campaigns).
## Estimating a draft
[`PATCH /v1/project/{project}/campaign/{id}/estimate` - Start the estimation of a campaign.](/platform-api/product-api/reference)
The call returns **`202 Accepted`** with the campaign in `estimating`, and a worker fills in the figures about a minute later. [Read the campaign again](/platform-api/product-api/campaigns/reading-campaigns) to collect them: `contactsCount` and `ignoredContactsCount` for the reach, `preparedUnits` for the message parts, and the campaign's price fields for the cost. While the worker is still running the status stays `estimating`.
- `precision` — `integer`, default: `derived from the audience size`
How many contacts the estimate samples, from 100 to 20000. A bigger sample takes longer and buys accuracy. The body itself is optional — send no body at all to take the default.
The estimate walks that sample and extrapolates, deliberately erring on the high side, so the real cost of the send is never above the figure you get back.
> **Warning**: **Estimating a `prepared` campaign changes it.** Estimating is a write and behaves like one: on a campaign that is already prepared it discards the prepared messages and returns the campaign to the estimation pipeline, so do not call it on a campaign somebody is about to confirm. The figures go before the worker even starts: the `202` itself already clears `contactsCount`, `preparedUnits` and the prices, so there is no window in which to change your mind.
>
> A `scheduled` campaign is refused outright with `409`, and unscheduling it in the dashboard is not the way around that: it leaves the campaign `prepared`, not back in draft, so estimating it afterwards still costs the preparation and it has to be prepared again before it can go out. To find out what a scheduled campaign costs, read the figures the previous estimate left on it with `GET /campaign/{id}` rather than starting a new one.
The states the endpoint accepts are `draft`, `preview`, `estimated`, `quoted` and `prepared`. See [Statuses and phases](/platform-api/product-api/campaigns/overview#statuses-and-phases) for what each one means.
### A `409` here is not only about state
This is the refusal an integration actually runs into, and the status alone will not explain it. **The campaign also has to be complete.** Every declared language needs its copy and a sender, so a draft missing its copy, missing a sender, or carrying a [language declared and left empty](/platform-api/product-api/campaigns/message#declaring-other-languages) is refused with the same `409` while sitting in a perfectly valid `draft`.
RCS adds a second gate: **the SMS fallback leg has to be complete too**, unless the campaign has no fallback or skips it. An RCS campaign whose own message is finished can still be refused because the fallback it inherited from the project has no sender — the part that fails is one the caller never wrote. The `no-default-sender` and `no-fallback-sender` [warnings](/platform-api/product-api/campaigns/creating-a-draft#warnings) returned at creation are what tell you this is coming.
## Deleting a campaign
[`DELETE /v1/project/{project}/campaign/{id}` - Delete a campaign.](/platform-api/product-api/reference)
> **Warning**: **Deletion is hard and there is no undo.** Nothing is archived and no copy is kept.
Anything that has not gone out can be deleted: a `draft` or `preview`, a campaign already `estimated` or `quoted`, one already `prepared`, and one still `scheduled` while its send time is **more than five minutes away**. A campaign that is sending, sent, aborted or unpaid is refused with `409`, and so is a scheduled one inside that five-minute edition window — for a campaign already on its way, cancelling in the dashboard is the alternative.
A campaign **mid-calculation cannot be deleted** either — neither `estimating` nor `quoting` is a deletable state, and this is the refusal most likely to surprise you, because you caused it yourself a moment earlier. If you have just started an estimate and want to undo it, wait for it to land and delete the campaign after.
### The `204` does not always mean it is gone
The response is **`204 No Content`** — there is no body to read, by design. And the deletion is not always immediate:
| The campaign was | What happens |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `draft`, `preview`, `estimated`, `quoted` | Gone by the time the call returns. |
| `prepared` or `scheduled` | Moved to `deleted` first. A worker removes its prepared messages before the campaign itself goes, so it keeps appearing in listings for a short while. |
With no body to tell the two apart, **read the campaign again**: a `404` means it is gone, and a campaign still there in `deleted` means the cleanup is running and will finish on its own. Neither is an error, and an integration that treats a post-delete `404` as a failure will report problems that did not happen.
## What's next
- [Reading campaigns](/platform-api/product-api/campaigns/reading-campaigns) - How you collect the result of both operations: one campaign, the list, and the project digest.
- [Campaigns](/platform-api/product-api/campaigns/overview) - The status vocabulary both operations are gated by, and the rest of the section.
- [Dates and scheduling](/platform-api/product-api/campaigns/scheduling) - Why a scheduled campaign is a different animal, and what setting a date does and does not do.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/overview
# Flows
How the Product API models a flow: a branching automation made of a trigger event, steps and exits. This page covers the drafts-only contract, the endpoints and scopes, and how a flow moves from an API draft to something a person publishes in the dashboard.
**Language:** en
**Audience:** developer
**TLDR:** A flow is a branching automation: one trigger event enrols a contact, who then follows their own path through sends, waits and branches until an exit event or the end. The Product API creates and edits flow DRAFTS only: POST /flow, GET and PUT /flow/{id}/draft, POST /flow/{id}/draft/validate, plus GET /flow/templates to start from a ready-made graph. Publishing, enabling, disabling and archiving stay in the dashboard. Reads need PROJECT_AUTOMATION_READ, writes PROJECT_AUTOMATION_WRITE.
**Search keywords:** flow, flows, flow api, journey, customer journey, branching automation, workflow, automation builder, flow draft, draft, publish flow, activate flow, enable flow, trigger, exit, exit event, steps, nodes, graph, lifecycle, PROJECT_AUTOMATION_WRITE, PROJECT_AUTOMATION_READ, what can I do to a flow
**Related pages:** /platform-api/product-api/flows/templates, /platform-api/product-api/flows/drafts, /platform-api/product-api/analytics/flows, /platform/en/automations/flows
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/overview/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/overview.md (Markdown)
A flow (Beta) is an automation that branches. One **trigger event** enrols a contact — a checkout started, an order placed, a contact created — and from there each contact follows their own path through the steps you place: messages, waits, and branches that route them depending on who they are or what they do. **Exit events** take a contact out early, typically when the goal of the journey happens: a reminder about an abandoned checkout stops the moment the order arrives.
The Product API lets an integration or an AI assistant turn "remind people who abandon their checkout" into a complete, validated flow that a person can open in the dashboard, review and publish. It does not let you switch one on.
> **Note**: **Flows is in beta**, in the dashboard and in this API. The endpoints and fields on these pages are the ones to build on, but the section may still grow and change while the feature evolves. For how customers build and publish flows in the dashboard, see [Flows](/platform/en/automations/flows) in the Platform guides.
## Drafts only
> **Warning**: **The API writes drafts, a person publishes.** Nothing in this section publishes, enables, disables or archives a flow, runs a test, or makes a contact enter one. A flow created through the API sends nothing until someone publishes it in the dashboard.
That split is deliberate. A live flow is what reaches contacts and spends balance, so the last step stays with a person who has seen the graph. What the API gives you is everything up to that point:
- **Start from a template.** `GET /flow/templates` returns ready-made flows scored against the events the project really receives, each with a graph that already validates. See [Templates](/platform-api/product-api/flows/templates).
- **Create, read, replace and validate the draft.** A flow is created together with its first draft, and the draft is always replaced whole. See [Creating and editing drafts](/platform-api/product-api/flows/drafts).
- **Write the graph.** The trigger and exits, the nodes you may place, and the delays. See [Triggers and exits](/platform-api/product-api/flows/triggers-and-exits), [Nodes](/platform-api/product-api/flows/nodes) and [Delays and waits](/platform-api/product-api/flows/delays).
## The endpoints
| Endpoint | What it does | Scope | Rate limit |
| -------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------- | ----------- |
| `GET /flow/templates` | Lists the templates worth offering this project, with a graph ready to create. | `PROJECT_AUTOMATION_READ` | medium |
| `POST /flow` | Creates a flow with its first draft. | `PROJECT_AUTOMATION_WRITE` | restrictive |
| `GET /flow/{id}/draft` | Reads the draft, with the `draftId` a replace expects. | `PROJECT_AUTOMATION_READ` | large |
| `PUT /flow/{id}/draft` | Replaces the whole draft. | `PROJECT_AUTOMATION_WRITE` | restrictive |
| `POST /flow/{id}/draft/validate` | Validates a graph without saving anything. | `PROJECT_AUTOMATION_WRITE` | large |
| `GET /flow` | Lists the project's flows, with each one's trigger and exits. | `PROJECT_AUTOMATION_READ` | large |
| `GET /flow/{id}` | Reads one flow. | `PROJECT_AUTOMATION_READ` | large |
| `GET /flow/{id}/versions` | Lists a flow's versions. See [Flow reports](/platform-api/product-api/analytics/flows#versions). | `PROJECT_AUTOMATION_READ` | large |
Every path is relative to `/v1/project/{project}`. Scopes are chosen when the token is minted, under **Project settings** → **API tokens**; `PROJECT_AUTOMATION_WRITE` only ever produces drafts. See [Authentication](/platform-api/product-api/authentication) and [Rate limits](/platform-api/product-api/rate-limits), where each class ceiling follows the organization's subscription plan.
The two write endpoints that save a draft also require the organization owner to be verified, like creating a campaign: an unverified organization gets a `403` with `code: "organization.verification_required"`.
## The life of a flow
A flow is a container with a status — `enabled`, `disabled` or `archived` — that points at **versions**. The API only ever writes one of them: the **draft**, the single editable version, which never runs on live traffic.
```mermaid
stateDiagram-v2
state "Draft only" as DraftOnly
[*] --> DraftOnly: created through the API (disabled, nothing published)
DraftOnly --> DraftOnly: draft replaced through the API or the dashboard
DraftOnly --> Enabled: a person publishes the draft in the dashboard
Enabled --> Disabled: a person disables it
Disabled --> Enabled: a person enables it
Enabled --> Archived: a person archives it
Disabled --> Archived: a person archives it
Archived --> [*]
class Enabled success
class Archived destructive
```
- **Creating** makes a flow that is disabled, with its draft and nothing published.
- **Publishing** copies the draft into a new, numbered version that new entrants follow. The draft stays as the base for the next change, so editing it later never changes what is live. The first publish switches the flow on by itself, unless the project is already at its plan's limit of enabled flows. A person can also publish a version as a live test that takes a small share of new entrants beside the live one.
- **Disabling** stops new entries and also halts the contacts already inside: they do not advance while it is off, and those kept waiting too long are cancelled. It is a stop, not a harmless pause.
- **Archiving** is final: the flow stops and its draft can no longer be replaced.
Versions, their roles and how to report on each one are covered in [Flow reports](/platform-api/product-api/analytics/flows#versions). The customer-facing description of the builder is [Flows](/platform/en/automations/flows) in the Platform guides.
## Reading flows
`GET /flow` and `GET /flow/{id}` return each flow's `id`, `name` and `status`, and two fields that help an integration before it creates anything:
- `template` — `string`
The key of the template the flow was created from, or `null`. A template stays marked `used` in [the template list](/platform-api/product-api/flows/templates) while a flow created from it is not archived.
- `trigger` — `object`
What the flow reacts to, taken from the live version or, when nothing is published, from the draft.
- `triggerEvent` — `string`
The event type that enrols a contact.
- `triggerDatasource` — `string`
The data source the trigger listens to. `null` means any source.
- `exitEvents` — `string[]`
The event types that take a contact out.
- `exitDatasources` — `string[]`
The data sources the exits listen to.
- `from` — `string`
`live` or `draft`: which version the trigger was read from.
> **Tip**: **List the flows before you create one.** Two flows on the same trigger event both enrol the same contact and message them twice. Checking `trigger.triggerEvent` across the existing flows is the cheapest way to avoid it. A disabled flow with a trigger may never have run: `from: "draft"` says nothing has been published.
## What's next
- [Templates](/platform-api/product-api/flows/templates) - Ready-made flows scored against the project's real events, in SMS or RCS, in the project's language.
- [Creating and editing drafts](/platform-api/product-api/flows/drafts) - Create, read, replace and validate a draft, and the errors each call returns.
- [Triggers and exits](/platform-api/product-api/flows/triggers-and-exits) - The trigger event and its source, who may enter, re-entry, exits and goals.
- [Nodes](/platform-api/product-api/flows/nodes) - How the graph chains, the nodes the API accepts, and how a send is written.
- [Delays and waits](/platform-api/product-api/flows/delays) - Fixed delays, window and smart delays configured by name, and waits for an event or a delivery.
- [Flow reports](/platform-api/product-api/analytics/flows) - Versions, and the time series that measures a published flow.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/templates
# Flow templates
GET /flow/templates returns the ready-made flows worth offering a project, scored against the events it really receives. This page covers relevance and scoring, the SMS and RCS modes, the language of the messages, the integration filter, and how to create a flow from a template.
**Language:** en
**Audience:** developer
**TLDR:** GET /v1/project/{project}/flow/templates lists ready-made flows scored for this project, each with a payload that validates once a sender is set. relevance is received (the trigger event arrived in the last 14 days), available (a connected source can send it) or unavailable. channel=rcs builds every send as RCS with the same SMS as fallback. Messages follow the project's language; lang only translates titles. Create one with POST /flow, passing the payload as draft and the key as template.
**Search keywords:** flow template, flow templates, template gallery, ready-made flow, prebuilt flow, flow recipe, recommended flows, abandoned checkout, abandoned cart, cart recovery, welcome flow, winback, win-back, review request, thank you, relevance, received, available, unavailable, score, scoring, recommended, used, payload, channel rcs, rcs fallback, sms fallback, lang, language, translation, localizations, integration filter, shopify, create from template
**Related pages:** /platform-api/product-api/flows/overview, /platform-api/product-api/flows/drafts, /platform-api/product-api/flows/triggers-and-exits, /platform/en/automations/flows
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/templates/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/templates.md (Markdown)
Writing a flow graph from nothing is the slowest and most error-prone way to build one. The template list is the shortcut: a catalogue of ready-made journeys (an abandoned-checkout reminder, a welcome, a winback and others), each **scored for this particular project** against the events it actually receives, and each carrying a complete graph with the trigger event and its source already chosen.
The intended path is to take the payload of the closest template, change only what you need, and create the flow from it.
The dashboard shows the same list as its template gallery, with the same recommendations; how customers use it is described in [Flows → Templates](/platform/en/automations/flows#templates).
[`GET /v1/project/{project}/flow/templates` - List the flow templates worth offering this project.](/platform-api/product-api/reference)
The endpoint takes the `PROJECT_AUTOMATION_READ` scope and sits in the **medium** [rate-limit](/platform-api/product-api/rate-limits) class.
## Query parameters
- `lang` — `string`, default: `en`
`en` or `es`. The language of each template's `title` and `description`. It does **not** change the language of the messages, which follow the project's own language (see [Message language](#message-language)).
- `channel` — `string`, default: `sms`
`sms` or `rcs`: the mode every `payload` is built in. See [SMS or RCS with SMS fallback](#sms-or-rcs-with-sms-fallback).
- `integration` — `string`
An integration key, such as `shopify`. Keeps only the templates that integration can feed, scored as if it were connected. See [What would an integration unlock](#what-would-an-integration-unlock).
## The response
```json
{
"entities": [
{
"key": "abandoned-checkout",
"title": "Abandoned checkout",
"description": "Reminds shoppers who started a checkout and did not buy within 30 minutes. Stops as soon as they order.",
"categories": ["ecommerce", "checkout_abandoned"],
"icon": "shopping-cart",
"recommended": true,
"integrations": [
{ "integration": "shopify", "name": "Shopify", "connected": true }
],
"channels": ["sms", "rcs"],
"channel": "sms",
"relevance": "received",
"trigger": {
"eventType": "ecommerce_checkout_init",
"datasourceId": "66c3a1b2e5a6b7c8d9e0f1a2",
"candidates": ["ecommerce_checkout_init"],
"lastSeenAt": "2026-09-25T08:41:12+00:00"
},
"score": 60,
"scoring": [
{ "signal": "relevance", "delta": 40, "text": "My Shop sent 412 ecommerce_checkout_init events in the last 30 days" },
{ "signal": "business", "delta": 20, "text": "Suited to ecommerce businesses" }
],
"used": false,
"payload": { "trigger": { "stepClass": "TRIGGER_CONTACT_EVENT", "eventType": "ecommerce_checkout_init", "...": "..." } }
}
],
"window": { "days": 30 }
}
```
- `key` — `string`
The template's stable identifier. Pass it as `template` when you create a flow from it.
- `title` — `string`
The template's name, in the `lang` you asked for.
- `description` — `string`
What the journey does, in the `lang` you asked for.
- `categories` — `string[]`
Free tags for grouping templates in a gallery.
- `icon` — `string`
A [Lucide](https://lucide.dev) icon name in kebab-case, fixed per template.
- `recommended` — `boolean`
`true` on at most three templates: the best-scored ones whose trigger event was received and that have not been used yet. The same set the project's [recommendations](/platform-api/product-api/project-setup/recommendations) surface.
- `integrations` — `object[]`
The integrations that can send the trigger event, connected first, each with `integration` (its key), `name` and `connected`. Empty for templates triggered by Instasent's own events, which fire whatever the source.
- `channels` — `string[]`
The send modes this project can use: `sms`, plus `rcs` when the project has an active RCS sender.
- `channel` — `string`
The mode `payload` was built in, which is the `channel` you asked for.
- `relevance` — `string`
`received`, `available` or `unavailable`. See [Relevance](#relevance).
- `trigger` — `object`
The trigger the template resolved to for this project.
- `eventType` — `string`
The event type the payload triggers on.
- `datasourceId` — `string`
The data source the payload's trigger listens to: the event's [preferred source](/platform-api/product-api/flows/triggers-and-exits#the-event-and-its-source). `null` for Instasent's own events, and when no connected source sends the event yet.
- `candidates` — `string[]`
The event types the template can trigger on, in order of preference. The first one this project receives wins.
- `lastSeenAt` — `string`
The last time the trigger event arrived inside `window`, whatever `relevance` says. On an `unavailable` template a value means the event is stale — it arrived, but more than 14 days ago — not unknown.
- `datasources` — `object[]`
The sources that send the trigger event, best first.
- `score` — `integer`
The template's score for this project. Only templates scoring above zero are listed.
- `scoring` — `object[]`
One entry per reason behind the score: `signal`, `delta` (points added or removed) and `text`, an English sentence built from the facts above.
- `used` — `boolean`
`true` while a flow created from this template exists and is not archived.
- `payload` — `object`
The flow graph, ready to send as the `draft` of [`POST /flow`](/platform-api/product-api/flows/drafts#creating-a-flow). Its shape is described in [Nodes](/platform-api/product-api/flows/nodes).
`window.days` is the window the event counts behind relevance and scoring cover. It follows the plan: 30 days on free plans, 180 on paid ones, the same bound the [event volumes probe](/platform-api/product-api/audience/probes#event-volumes) applies.
## Relevance
Relevance answers the question that decides whether a flow will ever fire: **does the event it triggers on actually arrive in this project?**
| `relevance` | What it means | What to do |
| ------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| `received` | A source that can trigger flows sent the trigger event in the last 14 days. | The flow fires today. The natural candidate to offer. |
| `available` | No such event arrived in the last 14 days, but a connected integration declares that it sends it. | The flow is buildable; it will fire once the events start arriving. |
| `unavailable` | No connected source sends the event, or it last arrived more than 14 days ago. | Connecting a source that sends it comes before building the flow. |
Relevance is **data first**. The events a project really receives outweigh the business type it declared, so a project that sells online while describing itself otherwise still sees the ecommerce journeys, as long as its orders arrive.
When a template can trigger on more than one event (a review request can follow a delivery or, failing that, a shipment), the first candidate the project receives is the one the payload uses, and a `scoring` sentence says so. A trigger filter in the graph follows the event it was rewritten to.
## Scoring and order
`score` is the sum of the `delta` of every `scoring` entry. The signals include the trigger's relevance (a received event weighs most), extra sources that also send it, how well the template suits the project's business type, whether the trigger is the project's own sales conversion event, and whether a complementary or overlapping flow already exists. Each entry says in plain words what it measured, so the list can be shown to a person, or read by a model, as the reason a template is on offer.
Templates are ordered with `used` ones last, then by score. Treat the weights as the platform's judgement, not a contract: read `scoring` rather than recomputing it.
## SMS or RCS with SMS fallback
Templates are authored as SMS. `channel=rcs` rebuilds every send in the payload as a **plain-text RCS message** with the same SMS, unchanged, as its [fallback](/platform-api/product-api/flows/nodes#fallback) for contacts RCS cannot reach. The consent policy is the same on both levels. There is no RCS-only mode.
- `rcs` is accepted only when the template's `channels` lists it, which means the project has an active RCS sender; otherwise the request is a `400`.
- The mode is **never chosen for you**. RCS messages are billed as RCS, so offer it as an option to the person who will publish the flow rather than switching to it on their behalf.
## Message language
The messages in `payload` are written in the **project's language**: Spanish for a Spanish project, English otherwise. `lang` only translates `title` and `description`.
When the organization's plan allows multi-language messages, every send in the payload also carries the other language as a translation, in the option's `localizations`. At send time each contact receives the translation matching their language, and anyone else receives the default text. See [Translations](/platform-api/product-api/flows/nodes#translations).
## What the payload leaves to you
A template payload validates as soon as one thing is added: **the sender of each send**. It is never set, on the message or on its translations, because which sender speaks for the brand is the customer's choice. Created without one, the flow is saved with `valid: false` and an error on each send, and it can be completed in the dashboard. See [The send](/platform-api/product-api/flows/nodes#the-send).
Everything else is already filled in the way the platform recommends:
- The trigger listens to the event's preferred source, and the exits to the same source.
- The audience is restricted to contacts with a mobile phone, so nobody enters a journey that can never send.
- Every marketing send waits in a [smart delay](/platform-api/product-api/flows/delays#smart-delay) that avoids illegal hours, and every send declares its consent policy.
- A contact is never inside two runs of the same flow at once, and journeys that should happen once (a welcome) or not too often (a checkout reminder) carry their own [re-entry limits](/platform-api/product-api/flows/triggers-and-exits#re-entry-and-caps).
## Creating a flow from a template
#### 1. List the templates
Call `GET /flow/templates` and pick the item to use: typically a `recommended` one, or the one the user asked for by name.
#### 2. Set the senders
Add the id of a sender from this project to each send in `payload`. Change anything else the user asked for, and nothing more.
#### 3. Create the flow
Send the payload as `draft` and the item's `key` as `template`, so the flow records where it came from and the template is marked `used`.
```bash
curl -X POST "$BASE/flow" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Abandoned checkout",
"template": "abandoned-checkout",
"draft": { "trigger": { "stepClass": "TRIGGER_CONTACT_EVENT", "eventType": "ecommerce_checkout_init" } }
}'
```
The `draft` above is shortened: send the whole `payload`. The response and its errors are in [Creating and editing drafts](/platform-api/product-api/flows/drafts#creating-a-flow). An unknown `template` key is a `400`.
## What would an integration unlock
`integration=` answers "which journeys would connecting this integration make possible?". It keeps only the templates that integration can feed and scores them as if it were connected:
- If the project **has** connected it, the templates score on its real data.
- If it has **not**, the integration counts as a source that has sent nothing yet, so its templates come back `available` with no `trigger.datasourceId`, unless another source already sends the event.
An unknown integration, or one that cannot trigger flows, is a `400`. Take the keys from the `integrations` of the items.
## Errors
| Status | When |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400` | `channel` is not one the project can use (`rcs` without an active RCS sender, or an unknown value), or `integration` is unknown or cannot trigger flows. |
| `401`, `403`, `429`, `5xx` | As described in [Errors](/platform-api/product-api/errors). |
## What's next
- [Creating and editing drafts](/platform-api/product-api/flows/drafts) - Create the flow from the payload, then read, replace and validate its draft.
- [Triggers and exits](/platform-api/product-api/flows/triggers-and-exits) - Why the trigger points at one source, and how exits and goals work.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/drafts
# Creating and editing drafts
The draft contract of a flow: creating a flow with its first draft, reading the draft back, replacing it whole with an expectedDraftId precondition, and validating a graph without saving it. Includes the response shapes, validation errors and advice, limits and error codes.
**Language:** en
**Audience:** developer
**TLDR:** POST /flow creates a flow with its first draft; a draft that does not validate is still saved, with valid: false and errors. GET /flow/{id}/draft returns the graph and its draftId. PUT /flow/{id}/draft replaces the whole draft and needs expectedDraftId (the draftId last read, or null); a stale one answers 409. POST /flow/{id}/draft/validate checks a graph without saving it. A project holds at most 50 never-published flows (409 flow-draft-limit-reached, with limit and count).
**Search keywords:** create flow, new flow, POST flow, flow draft, draft, draftId, expectedDraftId, replace draft, update flow, edit flow, PUT draft, optimistic concurrency, precondition, conflict, 409, stale draft, validate flow, validation, valid, errors, warnings, advice, lint, flow-draft-limit-reached, draft limit, unpublished flows, 422, 400, archived flow
**Related pages:** /platform-api/product-api/flows/overview, /platform-api/product-api/flows/templates, /platform-api/product-api/flows/nodes
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/drafts/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/drafts.md (Markdown)
A flow's draft is the one version the API can write. You create it together with the flow, read it back to change it, replace it whole, and validate graphs against the same checks the dashboard applies when it saves. Publishing it stays with a person in the dashboard — see [Flows](/platform-api/product-api/flows/overview#drafts-only).
Four endpoints, one shape: every draft travels as `{ "trigger": { ... } }`, the graph nested inside the trigger. That shape is described node by node in [Nodes](/platform-api/product-api/flows/nodes), and the fastest way to get a correct one is a [template payload](/platform-api/product-api/flows/templates).
## Before you start
#### 1. Get a Product API token with automation write access
Creating, replacing and validating need `PROJECT_AUTOMATION_WRITE`; reading the draft needs `PROJECT_AUTOMATION_READ`. Scopes are chosen when the token is minted, under **Project settings** → **API tokens** — see [Authentication](/platform-api/product-api/authentication).
#### 2. Know your project UID
Every call is scoped to one project, which owns the data sources, senders and segments the draft may reference.
#### 3. Export credentials
```bash
export INSTASENT_PROJECT="proj_xxx"
export INSTASENT_TOKEN="eyJhbGciOi..."
export BASE="https://api.instasent.com/v1/project/$INSTASENT_PROJECT"
```
## Creating a flow
[`POST /v1/project/{project}/flow` - Create a flow together with its first draft.](/platform-api/product-api/reference)
- `name` — `string`, required
The flow's name in the dashboard, up to 128 characters.
- `description` — `string`
An internal note about the flow.
- `tags` — `string[]`
Labels for organizing flows in the dashboard.
- `template` — `string`
The `key` of the [template](/platform-api/product-api/flows/templates) the flow is created from. Recorded once, at creation, and it marks the template as used. An unknown key is a `400`.
- `draft` — `object`, required
The graph, as `{ "trigger": { ... } }`. See [Nodes](/platform-api/product-api/flows/nodes).
```bash
curl -X POST "$BASE/flow" \
-H "Authorization: Bearer $INSTASENT_TOKEN" \
-H "Content-Type: application/json" \
-d @flow.json
```
A successful call returns **`201 Created`**:
```json
{
"entity": {
"flowId": "66b1f2a4e5a6b7c8d9e0f1a2",
"draftId": "66b1f2a4e5a6b7c8d9e0f1b7",
"valid": false,
"errors": [
{
"path": "graph",
"message": "...",
"parameters": { "errorsCount": 1, "uids": ["6f1c0a9e-..."] }
}
],
"warnings": [
{ "rule": "no-purchase-exit", "message": "..." }
]
}
}
```
- `flowId` — `string`
The new flow's id. The value every other flow endpoint takes, including the [flow reports](/platform-api/product-api/analytics/flows) once it is published.
- `draftId` — `string`
The id of the draft just saved. Keep it: it is the `expectedDraftId` of your next replace.
- `valid` — `boolean`
`false` means the draft cannot be published yet. `errors` says why.
- `errors` — `object[]`
The validation errors, each with `path`, `message` and `parameters`. Empty when `valid` is `true`. See [Validation errors](#validation-errors).
- `warnings` — `object[]`
Advice on a graph that validates but probably misbehaves, each with `rule` and `message`. See [Advice](#advice).
> **Note**: **An invalid draft is still saved**, exactly as the dashboard saves one: the flow exists, and its draft carries `valid: false` and the reasons. The usual gap is the **sender** of each send step, which a template payload leaves empty on purpose. Surface the errors to the person who will publish, or replace the draft once you have the missing values. Only a malformed request, not an invalid graph, is refused outright — see [Errors](#errors).
## Reading the draft
[`GET /v1/project/{project}/flow/{id}/draft` - Read the flow's draft in the same shape it is written.](/platform-api/product-api/reference)
The response is the same `flowId`, `draftId`, `valid` and `errors`, plus `draft`: the graph, with every node's `uid` kept so it can be edited and sent back whole. A flow with no draft answers `404`.
A draft can change without you: a person may open the flow in the dashboard and save it, and every save produces a new `draftId`. Read before you replace.
## Replacing the draft
[`PUT /v1/project/{project}/flow/{id}/draft` - Replace the whole draft.](/platform-api/product-api/reference)
There is **no partial edit**. Every save — yours or a person's in the dashboard — replaces the whole draft and creates a new draft version with its own id. The request carries the graph and the id of the draft you started from:
- `expectedDraftId` — `string`, required
The `draftId` you last read, or `null` when the flow has no draft. The field must be present even when it is `null`.
- `draft` — `object`, required
The complete graph, as `{ "trigger": { ... } }`.
If the flow's current draft is not the one you name, nothing is saved and the call answers **`409 Conflict`**: someone saved in between, and overwriting their change silently would lose it. Read the draft again, reapply your change on top of it, and send it with the new `draftId`.
```mermaid
flowchart TD
A[GET /flow/id/draft] --> B[Edit the graph, keep the uids]
B --> C[PUT /flow/id/draft with expectedDraftId]
C -->|200| D[Saved: keep the new draftId]
C -->|409| A
class D success
```
**Keep the `uid` of every node you read.** A node that keeps its `uid` keeps its identity across saves; omit it only on nodes you add.
A successful replace returns **`200 OK`** with `flowId`, the new `draftId`, `valid` and `errors`. Like creation, an invalid draft is saved. Editing the draft never changes what is live: the published version keeps running until a person publishes again.
Two things refuse a replace besides a stale draft: an **archived** flow, whose draft can no longer change (`400`), and a second save of the same flow within a couple of seconds of the previous one (`429`, retry after a moment).
## Validating without saving
[`POST /v1/project/{project}/flow/{id}/draft/validate` - Validate a graph against a flow without saving anything.](/platform-api/product-api/reference)
Send `{ "draft": { ... } }` and get back `valid`, `errors` and `warnings`, from the same checks a save runs. Nothing is stored, so it is the way to iterate on a graph before replacing a draft with it. The flow in the path must exist: create the flow first, then validate the changes you plan to make to it.
```json
{
"entity": {
"valid": true,
"errors": [],
"warnings": [
{ "rule": "send-right-after-trigger", "message": "..." }
]
}
}
```
### Validation errors
Validation covers the graph's structure, each node's required settings, and limits on the flow as a whole. Each entry carries `path`, `message` and `parameters`. Base your handling on `path` and `parameters`: `message` is written for the dashboard and can still contain `{{placeholders}}` that `parameters` fills in.
- **Problems with the graph as a whole** come as entries with `path: "graph"`: a node reachable from more than one place, more than **50** nodes, waits on the longest path adding up to more than **180 days**, or nodes that reference a sender or data source from outside this project (their `uids` are in `parameters`).
- **Problems inside nodes** come as a single `path: "graph"` entry whose `parameters` carry `errorsCount` and the `uids` of the nodes in error. The API does not return the reason per node; the dashboard editor shows it on each node. Keeping the `uid` of the nodes you read is what lets you tell which ones failed; a node you add gets its `uid` when the draft is saved, so read the draft back to map it.
The node problems you will meet most:
- **A send with no sender, or no text.** The most common, and the one every template payload has until you set its senders.
- **A branch with the wrong paths**: a missing path, a default path with a condition, the wrong number of fixed outcomes. See [Nodes](/platform-api/product-api/flows/nodes#branches).
- **A value out of range**: a delay shorter or longer than its node allows. See [Delays and waits](/platform-api/product-api/flows/delays).
### Advice
`warnings` flags a graph that validates but probably does not do what its author wants. It never blocks a save and is never stored; it rides the creation response and the validate response, not a replace.
| `rule` | What it flags |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `send-right-after-trigger` | The first step is a send. Put a [smart delay](/platform-api/product-api/flows/delays#smart-delay) first, unless the message confirms what the contact just did. |
| `no-purchase-exit` | A checkout or product journey with no exit on `ecommerce_order_create`, so a buyer keeps getting reminders. |
| `too-many-sends` | One path sends more than 3 messages. |
| `fixed-delay-before-send` | A fixed delay directly before a marketing send, which can land at any hour of the night. |
| `no-compliance-policy` | A send with no consent policy. See [The send](/platform-api/product-api/flows/nodes#the-send). |
Read every entry: each one describes a journey that will misbehave in front of contacts once it is published.
## Limits
| Limit | Value |
| ---------------------------------- | -------------------- |
| Flows never published, per project | 50 |
| Nodes in a graph | 50 |
| Total waits on the longest path | 180 days |
| `name` | up to 128 characters |
The first limit exists so an automated caller cannot fill a project with drafts in a loop; it is checked before any other work, and only on creation. A flow counts towards it until it is published, and stops counting once a person publishes or deletes it.
## Errors
| Status | When | What to do |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
| `400` | The body is not a JSON object; `draft` is missing or has no `trigger`; the draft uses a [node type the API does not accept](/platform-api/product-api/flows/nodes#the-nodes-the-api-accepts) (the message lists the allowed ones); `template` is unknown; `expectedDraftId` is absent; the flow is archived. | Fix the request. Retrying unchanged fails the same way. |
| `403` | The token lacks the scope, or the organization owner is not verified (`code: "organization.verification_required"`). | See [Errors](/platform-api/product-api/errors). |
| `404` | The flow does not exist in this project, or has no draft to read. | Check the id. |
| `409` | On replace: the draft changed since you read it. On create: the project is at its limit of never-published flows. | See below. |
| `422` | A value in the draft has the wrong shape, or it references a sender, data source or segment that does not exist in this project. | Read `errors.fields.draft`: it names the value and the expected shape. |
| `429` | The rate limit, or a second save of the same flow within a couple of seconds. | Back off and retry. |
The two `409`s are told apart by their body. The draft limit is the only one with a machine-readable code at the top level:
```json
{
"code": "flow-draft-limit-reached",
"message": "This project already has 50 flows that were never published, the most allowed. Publish or delete one before creating another; retrying will fail the same way.",
"limit": 50,
"count": 50
}
```
Retrying it changes nothing until a flow is published or deleted in the dashboard, so report it rather than looping. The stale-draft `409` carries only a `message`; its remedy is always to read the draft again.
A `422` looks like the other field errors of the Product API:
```json
{
"errors": {
"fields": {
"draft": ["A value in the draft has the wrong shape: ..."]
}
}
}
```
The most common cause is an RCS message written as a bare string: an RCS text is `{ "text": { "text": "..." } }`. Everything else — `401`, `5xx` — behaves as described in [Errors](/platform-api/product-api/errors).
## What's next
- [Nodes](/platform-api/product-api/flows/nodes) - The graph you send as `draft`, node by node.
- [Triggers and exits](/platform-api/product-api/flows/triggers-and-exits) - The trigger event and its source, re-entry, exits and goals.
- [Delays and waits](/platform-api/product-api/flows/delays) - How each kind of wait is configured, and what counts towards the 180 days.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/triggers-and-exits
# Triggers and exits
How a flow starts and stops, field by field: the trigger event and the one source it should listen to, Instasent's own events, the event filter, who may enter, the re-entry limits, the exits that take a contact out, and how goals are counted.
**Language:** en
**Audience:** developer
**TLDR:** Every flow has one trigger, TRIGGER_CONTACT_EVENT: an event type plus eventDatasource. Set eventDatasource to the source marked preferred for that event in POST /event/volumes; left null, the trigger fires once per copy when several sources send the same event. Only Instasent's own events (platformEventTypes, such as update or subscribe) stay null. Exits live in the trigger's exitSteps and end a contact's run; useAsGoal makes an exit count as a goal.
**Search keywords:** trigger, flow trigger, trigger event, TRIGGER_CONTACT_EVENT, eventType, eventDatasource, preferred source, preferred datasource, duplicate trigger, fires twice, relayed events, platform events, platformEventTypes, update event, new contact, welcome trigger, joined list, tag added, subscribe, unsubscribe, eventQueryFilter, event filter, fill rate, fillRate, audienceQueryFilter, excludeQueryFilters, re-entry, reentry, frequency cap, maxActivationsPerContact, minSecBetweenActivationsPerContact, maxConcurrentActiveFlowsPerContact, exit, exit event, exitSteps, stop flow, goal, useAsGoal, conversion goal, schedule trigger
**Related pages:** /platform-api/product-api/flows/overview, /platform-api/product-api/audience/probes, /platform-api/product-api/audience/events
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/triggers-and-exits/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/triggers-and-exits.md (Markdown)
Every flow has exactly one trigger, the root of the graph, and through the API it is always `TRIGGER_CONTACT_EVENT`: the flow starts when a contact produces an event. Whether a flow works depends less on the shape of the graph than on this node: whether its event really arrives, from which source, and what stops the journey once its purpose is met.
A trigger that fires on a schedule exists in the product but cannot be placed through the API; a draft carrying one is refused.
## The event and its source
- `eventType` — `string`, required
The event type uid, such as `ecommerce_checkout_init`. Take it from the [event catalogue](/platform-api/product-api/audience/events) or from the [event volumes probe](/platform-api/product-api/audience/probes#event-volumes), never from a display name. Only types whose `automation` is `true` in the catalogue can start a flow.
- `eventDatasource` — `string`
The id of the **one** data source the trigger listens to. `null` listens to every source.
**Point the trigger at the event's preferred source.** When two sources send the same event — a shop and a marketing tool that relays the shop's orders, for example — they are copies of one fact, and nothing merges them. A trigger with no source fires once per copy, so the same contact enters the flow twice for one purchase.
The [event volumes probe](/platform-api/product-api/audience/probes#where-each-event-comes-from) lists, for each event type, the sources that sent it, most authoritative first, and marks at most one of them `preferred`. For commerce events the shop that owns the orders outranks a tool that relays them; for anything else the fresher and busier source wins. Set `eventDatasource` to that id. The [templates](/platform-api/product-api/flows/templates) do this for you.
> **Tip**: If the event has been declared but has not arrived yet, [`GET /datasource/{id}`](/platform-api/product-api/reference) tells you what a connected integration is able to send: `metadata.capabilities.events` lists the event types it emits, and `delivery` says whether it pushes them as they happen (`realtime`) or they are fetched on a schedule (`periodic`), in which case they arrive in batches.
Some events are late by design. A shop's `ecommerce_checkout_abandon` is typically raised hours after the checkout stops changing, so a reminder triggered on it already carries that delay. Triggering on `ecommerce_checkout_init` and adding your own [delay](/platform-api/product-api/flows/delays), with an exit on the order, is the faster and more common choice.
## Instasent's own events
Some events are raised by the platform itself rather than by a source: the contact changed, subscribed, unsubscribed, clicked a tracked link. The probe lists them apart, in `platformEventTypes`, and they are the one case where `eventDatasource` stays `null`: they are raised on whatever source the contact belongs to, so no source is preferred.
| Event | Starts a flow when |
| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `update` | The contact changed. Filtered on `update.new-contact` being `true`, it is a contact **created** in that source: the welcome trigger, not `subscribe`. Filtered on `update.lists-added` or `update.tags-added` (the exact list or tag name), it is "joined list X" or "gained tag Y"; `lists-lost` and `tags-lost` are the reverse. |
| `subscribe`, `unsubscribe` | The contact's consent changed. |
| `campaign_cta`, `automation_cta`, `flow_cta`, `transactional_cta` | A tracked link in a message was clicked. |
Sends, opens, contact creation, merges and deletions are recorded as events too, but they cannot start a flow.
## The event filter
`eventQueryFilter` narrows the trigger to some occurrences of the event, by the event's own parameters. An empty value (`[]`) means every occurrence. Otherwise it is one `root` group in the [event query filter](/platform-api/product-api/audience/event-query-filter) grammar, and every key is `.`:
```json
{
"root": {
"type": "group",
"join": "and",
"children": [
{
"type": "event_condition",
"key": "ecommerce_order_create.order-euro-amount",
"operator": "range-number",
"values": { "lowerNumber": 100 }
}
]
}
}
```
Because every key is prefixed with the event type, a filter belongs to one event: change `eventType` and rewrite the filter.
**Filter only on values that actually arrive.** Probe the event first with `"event": ""` on [`/event/volumes`](/platform-api/product-api/audience/probes#one-level-deeper-what-the-parameters-carry): it lists the real values of each parameter and its `fillRate`, the share of events that carry it at all. A filter on a value that never occurs, or on a parameter the source never fills (product tags are empty on many stores), produces a flow that is valid and never fires.
## Who may enter
- `audienceQueryFilter` — `object`
An [audience query filter](/platform-api/product-api/audience/query-filter) the contact must match when entering. Empty means everyone. To use a segment, carry its uid in `metadata.segment.uid` next to its `root`: a filter tied to a segment is refreshed from that segment when the flow is published and while it runs, while one without a uid is kept exactly as written. The templates use `dyn-generic-has-phone` this way, so nobody without a mobile phone enters a journey that can never send.
- `excludeQueryFilters` — `object[]`
Filters in the same shape. A contact matching any of them does not enter.
- `cancelIfExitsAudienceSegment` — `boolean`
When `true`, a contact who stops matching the audience while inside the flow, exclusions included, is taken out.
Segment uids come from [`GET /segment`](/platform-api/product-api/audience/segments).
## Re-entry and caps
A contact who hits any of these limits does not enter, and the attempt is dropped, not queued.
- `maxActivationsPerContact` — `integer`
Entries per contact over the flow's lifetime, across every version. `0` means unlimited; `1` means once ever, which is what a welcome wants.
- `minSecBetweenActivationsPerContact` — `integer`
Seconds that must pass since this contact last entered. A checkout reminder uses a day, so it does not run twice for the same shopper in one afternoon.
- `maxConcurrentActiveFlowsPerContact` — `integer`
Runs of **this** flow one contact may have in progress at once. It is not a limit across flows: two flows on the same trigger both run.
- `maxActivationsPerHour` — `integer`
Entries into the whole flow per hour, all contacts together.
- `maxActivationsPerDay` — `integer`
Entries into the whole flow per day, all contacts together.
The plan also applies its own ceiling on entries when the flow runs, so the effective cap can be lower than the value stored.
Entry is not instantaneous: each entry waits a short moment so the flow reads the contact as it is after the trigger event, and under heavy traffic entries may be held a little longer — never beyond the waits the flow already has before its first send, so send times do not move.
## Exits
Exits live on the trigger, in `exitSteps`, never in the chain of steps. Each one is an `ACTION_SEQUENTIAL_EXIT_TRIGGER_EVENT` with the same fields as the trigger — `eventType`, `eventDatasource`, `eventQueryFilter` — and the same rules: prefix filter keys with the exit's own event type, and point it at the same preferred source as the trigger, or it will also fire on relayed copies. A trigger holds at most 10 exits.
```json
{
"stepClass": "TRIGGER_CONTACT_EVENT",
"eventType": "ecommerce_checkout_init",
"eventDatasource": "66c3a1b2e5a6b7c8d9e0f1a2",
"exitSteps": [
{
"stepClass": "ACTION_SEQUENTIAL_EXIT_TRIGGER_EVENT",
"eventType": "ecommerce_order_create",
"eventDatasource": "66c3a1b2e5a6b7c8d9e0f1a2",
"eventQueryFilter": [],
"useAsGoal": true
}
],
"maxConcurrentActiveFlowsPerContact": 1,
"minSecBetweenActivationsPerContact": 86400,
"nextStep": { "...": "..." }
}
```
- **An exit event received while the contact is inside the flow ends the run.** Nothing further is sent.
- **An exit event that already arrived when the contact is about to enter keeps them out.** Since entry happens shortly after the trigger rather than at the same instant, a shopper who orders moments after abandoning a checkout is never messaged.
- **An exit on the trigger's own event restarts the clock.** The event that started the run never ends it; the next one does, and starts a fresh run if the re-entry limits allow. That is how a winback waits from the contact's **last** order rather than their first.
Every reminder needs a way out when its purpose happens: a checkout or product journey without an exit on `ecommerce_order_create` is flagged by the [draft advice](/platform-api/product-api/flows/drafts#advice).
## Goals
A goal is how a flow measures that it did its job.
- **`useAsGoal: true` on an exit** makes it a goal: the run ends by goal instead of a plain exit. Set it only on exits that are objectives — purchased, booked — and never on housekeeping exits such as `unsubscribe`, or the goal figures inflate.
- **`ACTION_SEQUENTIAL_GOAL`**, a node in the graph, marks a goal by position: the contact reached this point. It does not send anything or stop the run. See [Nodes](/platform-api/product-api/flows/nodes#sequential-nodes).
A goal counts only if the run had already sent the contact at least one message; one reached before any message is reported apart. The goal rate is runs with a goal over runs that sent at least one message, so it never exceeds 100%. A goal happens at most once per run and carries no money: revenue and conversions come from [attribution](/platform-api/product-api/analytics/conversions), so a flow's goal count and its conversion count can legitimately differ.
## What's next
- [Nodes](/platform-api/product-api/flows/nodes) - What goes after the trigger: sends, branches and the other nodes.
- [Audience probes](/platform-api/product-api/audience/probes) - The event volumes probe: sources per event, the preferred one, and fill rates.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/nodes
# Nodes
The graph a flow draft carries: how nodes chain through nextStep and paths, why relations are bare ids, the node types the API accepts, how a send is written (sender, text, consent policy, translations, RCS fallback), and every branch.
**Language:** en
**Audience:** developer
**TLDR:** A draft is { trigger: {...} } with the graph nested inside. Sequential nodes continue on nextStep; branches have paths (and usually a defaultPath) that rejoin on the branch's own nextStep; the graph is a tree. Relations are bare ids of this project's senders, data sources and segments. Only the stepClass values listed here are accepted; the AI delay and the schedule trigger are refused with a 400. Every send needs a sender, a text and a compliancePolicy.
**Search keywords:** node, nodes, step, steps, stepClass, graph, flow graph, export shape, nextStep, paths, defaultPath, branch, branches, uid, relation, bare id, ACTION_SEQUENTIAL_SEND, send step, sender, compliancePolicy, consent policy, no-opt-out, localizations, translation, multi-language, RCS fallback, SMS fallback, skipFallback, tag contact, add to list, subscription manage, opt-out step, goal node, end node, A/B test, split test, consent branch, segment branch, attribute branch, channel capability, allowed node types, AI delay, not allowed
**Related pages:** /platform-api/product-api/flows/triggers-and-exits, /platform-api/product-api/flows/delays, /platform-api/product-api/flows/drafts
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/nodes/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/nodes.md (Markdown)
A flow draft is a single JSON object, `{ "trigger": { ... } }`, with the whole graph nested inside the trigger. Every node is an object whose `stepClass` names its type. It is the same shape the draft read endpoint returns and every template payload carries, so the most reliable way to write one is to start from a [template](/platform-api/product-api/flows/templates) and change it.
This page covers what comes after the trigger. The trigger itself, its exits and its re-entry settings are in [Triggers and exits](/platform-api/product-api/flows/triggers-and-exits); the five kinds of wait are in [Delays and waits](/platform-api/product-api/flows/delays).
## How nodes chain
- **A sequential node continues on `nextStep`**, one node object. A node with no `nextStep` ends that line.
- **A branch node has `paths`** and, on most branches, a `defaultPath`. Each path is `{ "name"?, "nextStep", ...its condition }`. When a path's chain ends, the contact continues on the branch's **own** `nextStep`, shared by every path; a path with no `nextStep` goes straight there.
- **There are no merges or loops.** The graph is a tree, and a node reachable from more than one place is a validation error.
- **Exits are not nodes in the chain.** They live in the trigger's `exitSteps`.
- **`uid` is optional** on every node and path. Keep the one you read so the node keeps its identity across saves; omit it on nodes you add.
```json
{
"trigger": {
"stepClass": "TRIGGER_CONTACT_EVENT",
"eventType": "update",
"eventDatasource": null,
"eventQueryFilter": { "root": { "type": "group", "join": "and", "children": [
{ "type": "event_condition", "key": "update.new-contact", "operator": "matches-bool", "values": [true] }
] } },
"maxActivationsPerContact": 1,
"nextStep": {
"stepClass": "ACTION_BRANCH_SMART_DELAY",
"minDelaySeconds": 300,
"maxDelaySeconds": 86400,
"preset": "recommended",
"urgency": "balanced",
"legalHours": true,
"paths": [
{
"outcome": "waitComplete",
"nextStep": {
"stepClass": "ACTION_SEQUENTIAL_SEND",
"channelType": "sms",
"channel": {
"channelType": "sms",
"compliancePolicy": "no-opt-out",
"options": [
{ "sender": "67bdfa983114d0062d733795", "template": "Welcome, {{_first_name}}! Unsubscribe: {{unsubscribe}}", "allowUnicode": false }
]
}
}
},
{ "outcome": "cannotSatisfy" }
]
}
}
}
```
## Relations are bare ids
A node that references something else — a sender, a data source, a segment — takes **the bare id string**, never an object such as `{ "id": "..." }`. The read shape of some other endpoints uses objects; the draft does not (an object carrying an `id` is tolerated and flattened, but write the string).
Every reference must belong to **this project**: sender ids from `GET /channel/sms/sender` or `GET /channel/rcs/sender`, data source ids from the [event volumes probe](/platform-api/product-api/audience/probes#where-each-event-comes-from), segment uids from [`GET /segment`](/platform-api/product-api/audience/segments). An id that does not exist is a `422`; one that exists in another project or organization is a validation error on the draft. Copy ids from those responses; never retype or guess them.
## The nodes the API accepts
Only these `stepClass` values may be placed through the API. A draft containing any other is refused with a `400` before anything is saved, and the message lists the allowed ones.
| Kind | `stepClass` |
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Trigger and exits | `TRIGGER_CONTACT_EVENT`, `ACTION_SEQUENTIAL_EXIT_TRIGGER_EVENT` |
| Messages | `ACTION_SEQUENTIAL_SEND` |
| Contact changes | `ACTION_SEQUENTIAL_CONTACT_TAG`, `ACTION_SEQUENTIAL_CONTACT_LIST`, `ACTION_SEQUENTIAL_SUBSCRIPTION_MANAGE` |
| Outcomes | `ACTION_SEQUENTIAL_GOAL`, `ACTION_SEQUENTIAL_END` |
| Waits | `ACTION_SEQUENTIAL_DELAY`, `ACTION_BRANCH_TIMEZONE_DELAY`, `ACTION_BRANCH_SMART_DELAY`, `ACTION_BRANCH_CONTACT_EVENT_WAIT`, `ACTION_BRANCH_MESSAGE_DELIVERY_WAIT` |
| Branches | `ACTION_BRANCH_CONTACT_CONSENT`, `ACTION_BRANCH_CONTACT_TAG`, `ACTION_BRANCH_CONTACT_LIST`, `ACTION_BRANCH_CONTACT_SEGMENT`, `ACTION_BRANCH_CONTACT_ATTRIBUTE`, `ACTION_BRANCH_TRIGGER_EVENT`, `ACTION_BRANCH_CONTACT_EVENT_SEARCH`, `ACTION_BRANCH_CHANNEL_CAPABILITY`, `ACTION_BRANCH_ABTEST` |
The dashboard has a few more — a delay where a model picks the moment, and a trigger that fires on a schedule — which cannot be placed through the API. A flow built in the dashboard with one of them can be read, but a replace that carries it back is refused, so leave such flows to the dashboard.
## The send
`ACTION_SEQUENTIAL_SEND` sends one SMS or RCS message and moves on at once: it has no success or failure path. To act on whether the message arrived, follow it with a [delivery wait](/platform-api/product-api/flows/delays#waiting-for-delivery).
- `channelType` — `string`, required
`sms` or `rcs`.
- `abortFlowIfUnableToSend` — `boolean`, default: `true`
When nothing can go out to this contact (no phone, no consent, a blocked number), `true` ends the run here and `false` lets it continue.
- `channel` — `object`, required
The message.
- `channelType` — `string`, required
Repeats the node's `channelType`.
- `compliancePolicy` — `string`
The consent policy this send applies: `no-opt-out` for marketing journeys, `opt-in` to reach only contacts with explicit consent, `basic` for a message that confirms what the contact just did. The values mean what they mean on a campaign — see [Consent policy](/platform-api/product-api/campaigns/audience#consent-policy). **Always set it**: left out, the send also reaches contacts who refused marketing, and the draft advice flags it.
- `options` — `object[]`, required
Exactly one entry.
- `sender` — `string`
The id of a sender of this project for the channel. A send without one is saved but does not validate.
- `template` — `string | object`
The message. For SMS, the text, with [template variables](/platform-api/product-api/campaigns/message#template-variables) such as `{{short:...}}` and `{{unsubscribe}}`, plus `{{_first_name}}` and `{{_event.}}`, which reads a parameter of the event that triggered the flow. For RCS, an RCS message object: a plain-text one is `{ "text": { "text": "..." } }`, never a bare string.
- `allowUnicode` — `boolean`
**SMS only.** Allows non-GSM characters, which lower the characters per billed part.
- `localizations` — `object[]`
Translations. See [Translations](#translations).
- `fallback` — `object`
**RCS only.** See [Fallback](#fallback).
### Translations
An option may carry `localizations: [{ "language", "template" }]`: the same message in other languages. At send time each contact receives the translation matching their language, and anyone else receives the option's own `template`. A translation uses the option's sender; leave its `sender` out and it takes it on save, so setting the option's sender is enough. Multi-language messages depend on the organization's plan; template payloads only carry a translation when the plan allows it.
### Fallback
An RCS channel may carry `fallback`: another channel object — in practice an `sms` one, with its own sender and text — sent when RCS cannot reach the contact. It applies the parent's consent policy.
- An SMS channel cannot have a fallback, and a channel cannot fall back to its own type.
- `skipFallback: true` switches the fallback off without deleting it.
- `sendUnsupported` (default `true`) also sends RCS to contacts whose RCS support is unknown.
With `channel=rcs`, the [template list](/platform-api/product-api/flows/templates#sms-or-rcs-with-sms-fallback) builds every send this way: a plain-text RCS message with the original SMS as its fallback. Both levels need a sender.
## Sequential nodes
All continue on `nextStep`.
| `stepClass` | What it does | Fields |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `ACTION_SEQUENTIAL_CONTACT_TAG` | Adds or removes tags on the contact, as a manual edit would. | `mode` (`add`, the default, or `remove`), `tags` (at least one). |
| `ACTION_SEQUENTIAL_CONTACT_LIST` | Adds the contact to lists or removes them. | `mode`, `lists` (at least one). |
| `ACTION_SEQUENTIAL_SUBSCRIPTION_MANAGE` | Sets one consent state, as a manual change would. | `channel` (`sms`, which also covers RCS), `operation` (`opt-in`, `opt-out`, `suppress` or `reactivate`). |
| `ACTION_SEQUENTIAL_GOAL` | Marks the run as having reached the flow's goal, once. Sends nothing, stops nothing. See [Goals](/platform-api/product-api/flows/triggers-and-exits#goals). | — |
| `ACTION_SEQUENTIAL_END` | Ends the run here, without continuing on an enclosing branch's `nextStep`. | No fields, no `nextStep`. |
| `ACTION_SEQUENTIAL_DELAY` | A fixed wait. See [Delays and waits](/platform-api/product-api/flows/delays#fixed-delay). | `delaySeconds`. |
## Branches
### Fixed outcomes
The node decides which paths exist; you fill each path's `nextStep`.
- **`ACTION_BRANCH_CONTACT_CONSENT`** routes by whether the contact would pass a consent check: `channelTypes` (`sms`, `rcs`), `channelMatchMode` (`all` or `any`) and `compliancePolicy`. `paths` holds exactly one `"outcome": "allowed"` and one `"outcome": "blocked"`, and there is no `defaultPath`. It routes, it does not replace the send's own policy. To split a journey by consent, use this branch rather than a segment.
- **`ACTION_BRANCH_TIMEZONE_DELAY`** and **`ACTION_BRANCH_SMART_DELAY`**: one `waitComplete` and one `cannotSatisfy` path. See [Delays and waits](/platform-api/product-api/flows/delays).
- **`ACTION_BRANCH_MESSAGE_DELIVERY_WAIT`**: paths by delivery outcome. See [Waiting for delivery](/platform-api/product-api/flows/delays#waiting-for-delivery).
### Conditions
`paths` are evaluated in order and the first that matches wins; `defaultPath`, which is required and carries no condition, takes everyone else.
| `stepClass` | Each path | Notes |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------- |
| `ACTION_BRANCH_CONTACT_TAG` | `{ "mode": "oneOf" \| "noneOf", "values": [...] }`, values being tag names | Reads the contact as it is now. |
| `ACTION_BRANCH_CONTACT_LIST` | Same, with list names | Reads the contact as it is now. |
| `ACTION_BRANCH_CONTACT_SEGMENT` | Same, with segment uids | A segment may lag the contact by a few seconds. |
| `ACTION_BRANCH_CONTACT_ATTRIBUTE` | `filter`: an [audience query filter](/platform-api/product-api/audience/query-filter) over the contact | For what the three above do not cover. |
| `ACTION_BRANCH_TRIGGER_EVENT` | `filter`: a filter over the event that started the run | |
| `ACTION_BRANCH_CONTACT_EVENT_SEARCH` | `eventType`, optional `eventDatasource`, `withinDays`, `sinceEntry` (default `true`: only since the contact entered the flow) and `filter` | Did an event happen. |
| `ACTION_BRANCH_CHANNEL_CAPABILITY` | `states`, from `supported`, `notSupported` and `unknown`, for the node's `channel` (`sms` or `rcs`) | Reads stored reachability; the send decides again anyway. |
The branch that pauses until the contact acts, `ACTION_BRANCH_CONTACT_EVENT_WAIT`, is a wait as much as a branch: see [Waiting for an event](/platform-api/product-api/flows/delays#waiting-for-an-event).
### Random split
`ACTION_BRANCH_ABTEST` splits contacts at random. Each entry of `paths` has an integer `percentage`, and `defaultPath` carries the remainder as its own `percentage`. The paths must leave the default a share.
## What's next
- [Delays and waits](/platform-api/product-api/flows/delays) - Fixed, window and smart delays, and the waits for an event or a delivery.
- [Creating and editing drafts](/platform-api/product-api/flows/drafts) - Save the graph, validate it, and read the errors it returns.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/delays
# Delays and waits
Every way a flow waits: the fixed delay, the window delay in the contact's local time, the smart delay configured by name with a preset, an urgency and legal hours, and the two waits that pause until the contact acts or a message is delivered.
**Language:** en
**Audience:** developer
**TLDR:** Five nodes pause a flow. ACTION_SEQUENTIAL_DELAY waits an exact time (10 s to 90 days) and ignores the clock. ACTION_BRANCH_SMART_DELAY waits for each contact's best slot within a window of up to 14 days: configure it by preset, urgency and legalHours, keep minDelaySeconds at 300 or more (300 is the default), and put the send on its waitComplete path. ACTION_BRANCH_CONTACT_EVENT_WAIT and ACTION_BRANCH_MESSAGE_DELIVERY_WAIT pause until the contact acts or a message is delivered.
**Search keywords:** delay, delays, wait, waits, fixed delay, ACTION_SEQUENTIAL_DELAY, delaySeconds, timezone delay, window delay, ACTION_BRANCH_TIMEZONE_DELAY, allowedHours, allowedDaysOfWeek, quiet hours, send time, best time to send, send time optimization, smart delay, ACTION_BRANCH_SMART_DELAY, preset, recommended, purchase, engagement, urgency, bestTime, relaxed, balanced, soon, asap, legalHours, legal hours, night hours, minDelaySeconds, maxDelaySeconds, waitComplete, cannotSatisfy, contextProviders, decayFactor, wait for event, wait for reply, wait for click, ACTION_BRANCH_CONTACT_EVENT_WAIT, matchers, timeout, delivery wait, ACTION_BRANCH_MESSAGE_DELIVERY_WAIT, delivered, notDelivered, unableToSend
**Related pages:** /platform-api/product-api/flows/nodes, /platform-api/product-api/flows/drafts, /platform-api/product-api/flows/templates
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/delays/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/flows/delays.md (Markdown)
Timing decides more of a flow's results than its copy does. A reminder that lands at 3 a.m. annoys; one that lands the instant a checkout starts looks like surveillance; one that lands days late misses the moment. Five nodes control when things happen: three wait for a **time**, two wait for **something to happen**.
Every wait counts towards the limit on a flow's longest path: the waits along any path may add up to at most 180 days, and [validation](/platform-api/product-api/flows/drafts#validation-errors) reports a flow that exceeds it.
| Node | Waits for | Paths |
| ------------------------------------- | ----------------------------------------------------- | --------------------------------------- |
| `ACTION_SEQUENTIAL_DELAY` | An exact amount of time. | `nextStep` |
| `ACTION_BRANCH_TIMEZONE_DELAY` | The first allowed moment in the contact's local time. | `waitComplete`, `cannotSatisfy` |
| `ACTION_BRANCH_SMART_DELAY` | The best allowed moment for that contact. | `waitComplete`, `cannotSatisfy` |
| `ACTION_BRANCH_CONTACT_EVENT_WAIT` | The contact to do something, or a timeout. | One per matcher group, plus the timeout |
| `ACTION_BRANCH_MESSAGE_DELIVERY_WAIT` | The result of the previous send. | By delivery outcome |
## Which one to use
- **Before a marketing message, a smart delay.** Including before the first one: it waits at least its minimum, picks each contact's best moment, and with `legalHours` never lands at an hour when marketing is not allowed.
- **For an exact gap, a fixed delay.** Two messages ten minutes apart, for example. It does not look at the clock, so do not put one directly before a marketing send.
- **For a wait longer than 14 days, a fixed delay followed by a smart delay.** A winback that waits 30 days after the last order uses a 30-day fixed delay, then a smart delay to pick the moment.
- **Only a message that confirms what the contact just did** — an order confirmation — goes out with no delay at all.
## Fixed delay
`ACTION_SEQUENTIAL_DELAY` counts down from the moment the contact reaches it, then continues on `nextStep`. No time zone, no allowed hours, no paths.
- `delaySeconds` — `integer`, required
From 10 seconds to 90 days.
## Window delay
`ACTION_BRANCH_TIMEZONE_DELAY` waits for the first allowed moment in the contact's **local** time zone, inside a window.
- `minDelaySeconds` — `integer`
The earliest it may resolve. `0` means as soon as an allowed moment comes.
- `maxDelaySeconds` — `integer`, required
The latest it may resolve. Greater than the minimum, at least 8 hours and at most 14 days.
- `allowedHours` — `integer[]`
Hours from 0 to 23, local time. Empty means every hour, not none.
- `allowedDaysOfWeek` — `integer[]`
Days from 1 (Monday) to 7 (Sunday). Empty means every day.
- `paths` — `object[]`, required
Exactly two, fixed: `{ "outcome": "waitComplete" }`, taken once the allowed moment arrives, and `{ "outcome": "cannotSatisfy" }`, taken at once when no moment in the window fits the hours and days. The run is not aborted on `cannotSatisfy`: you decide what that path does. Paths cannot be added, removed or renamed.
Allowed hours and days are the brand's own courtesy calendar. The platform adds no legal restriction here; that is the smart delay's `legalHours`.
## Smart delay
`ACTION_BRANCH_SMART_DELAY` has the same window, allowed hours and days, and the same two paths as the window delay. Instead of taking the first allowed moment, it scores every allowed moment and waits for the best one **for that contact**. Configure it **by name**, and always send all three names:
- `preset` — `string`
What ranks the moments. `recommended`, a balanced mix; `purchase`, where the project's and the contact's purchases and checkouts weigh most, for carts and upsells; `engagement`, where the contact's own replies and clicks weigh most. `advanced` is for hand-written signals, below.
- `urgency` — `string`
How strongly sooner moments are preferred, from least to most: `bestTime`, `relaxed`, `balanced`, `soon`, `asap`. A cart reminder wants `soon` or `asap`; a winback, `balanced` or `relaxed`.
- `legalHours` — `boolean`
`true` before a marketing message, `false` before a transactional one. See [Legal hours](#legal-hours).
- `minDelaySeconds` — `integer`, default: `300`
The minimum wait. Keep it at five minutes or more, even before the first send, so a marketing message never lands in the same instant as the event that triggered it. `0` is accepted when that is really what you want.
- `maxDelaySeconds` — `integer`, required
The end of the window: at least 8 hours, at most 14 days.
- `allowedHours` — `integer[]`
As on the window delay.
- `allowedDaysOfWeek` — `integer[]`
As on the window delay.
```json
{
"stepClass": "ACTION_BRANCH_SMART_DELAY",
"minDelaySeconds": 1800,
"maxDelaySeconds": 28800,
"preset": "purchase",
"urgency": "soon",
"legalHours": true,
"paths": [
{ "outcome": "waitComplete", "nextStep": { "stepClass": "ACTION_SEQUENTIAL_SEND", "...": "..." } },
{ "outcome": "cannotSatisfy" }
]
}
```
**Put the send inside the `waitComplete` path.** `cannotSatisfy` means no allowed — or legal — moment fitted the window, so nothing should send there. A send placed after the two paths rejoin goes out anyway, at a possibly illegal hour. A narrow window with tight hours or days sends more contacts to `cannotSatisfy`; widen `maxDelaySeconds` rather than drop the hours the brand asked for.
What happens when a name is missing:
- **No `preset`** (and no hand-written signals): the node is saved with nothing to rank by and no legal filter. Nothing fills it in for you.
- **No `urgency`**: a balanced default applies.
- **`legalHours` absent or `null`**: on, with any named preset.
- **A name wins over hand-written values.** With `preset` set, the signals are rebuilt from it on every save; with `urgency` set, a raw urgency value is ignored. To change the behaviour, change the name.
#### Technical details: the advanced form
With `preset: "advanced"`, the node carries `contextProviders`, a list of `{ "contextClass", "relevance" }` with `relevance` from 0 to 1000 and `contextClass` one of `SEND_TIME_MARKETING_BEST_PRACTICE`, `EVENTS_HOURLY_SALES`, `EVENTS_HOURLY_CHECKOUTS`, `CONTACT_SALES`, `CONTACT_CHECKOUTS` and `CONTACT_INTERACTIONS`. The legal filter is the entry `{ "contextClass": "SEND_TIME_MARKETING_COMPLIANCE" }`, with no relevance. `decayFactor` is the raw urgency, a number below 1, where lower prefers sooner. A named preset is almost always enough.
### Legal hours
`legalHours` removes the moments at which sending marketing is not allowed in the contact's country. It acts only where a country restricts marketing hours or days; elsewhere, and when the contact's country cannot be determined, every moment passes. It does not replace the brand's `allowedHours`, and it applies to marketing only: a transactional message is not held back by it.
## Waiting for an event
`ACTION_BRANCH_CONTACT_EVENT_WAIT` pauses until the contact does something, and branches by what they did first.
- `maxWaitSeconds` — `integer`, required
From 10 seconds to 90 days. When it runs out, the contact takes `defaultPath`.
- `paths` — `object[]`, required
From 1 to 20, evaluated top to bottom. Each has `matchers` (any one of them matches the path) and an optional `name`.
- `defaultPath` — `object`, required
The timeout path, with `"matchers": []`.
Matchers, by their `by` field:
| `by` | Matches | Fields |
| -------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------- |
| `event` | An event received after the contact reached the node. | `eventType`, `datasource` (required), optional `filter` |
| `inboundText` | A reply matching a pattern. | `patterns`, `unaccent` |
| `interactionToken` | A specific interaction with the message. | `tokens` |
| `anyClick`, `anyTap`, `anyInboundText` | Any click, tap or reply. | — |
| `unsubscribed` | The contact unsubscribed. | `kind`: `optOut`, `suppression` or `any` (default) |
| `subscribed` | The contact subscribed. | — |
- **Put catch-all matchers last.** A path with `anyClick`, `anyTap` or `anyInboundText` must come after every path with a specific matcher, or it would take their contacts.
- **Click, tap, token and reply matchers listen to the message the flow just sent.** Without a send before the node on that path, they can only time out.
- **For "they left", use `kind: any`.** A contact already suppressed who unsubscribes again arrives only as `suppression`.
## Waiting for delivery
`ACTION_BRANCH_MESSAGE_DELIVERY_WAIT` binds itself to the send just before it and branches on its result. There is no timeout to configure: a fixed platform window applies, and a message with no result by then goes to `notDelivered`. An interaction with the message proves delivery early.
- `outcomeMode` — `string`, default: `delivery`
`delivery`: one `delivered` and one `notDelivered` path. `channel`: `delivered` paths split by `channel` (`sms` or `rcs`, at most one without a channel), plus one `notDelivered`. There is no `defaultPath`.
An optional `unableToSend` path is taken at once when the send was never attempted — no phone, no consent, a blocked number. Without it, those contacts go to `notDelivered`, mixed with real delivery failures.
With no send before it, `delivered` can never be reached: every contact goes straight to `unableToSend` or `notDelivered`.
## What's next
- [Nodes](/platform-api/product-api/flows/nodes) - The rest of the graph: sends, branches and contact changes.
- [Creating and editing drafts](/platform-api/product-api/flows/drafts) - Validate the timing before you save it.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/overview
# Analytics
Curated, read-only performance reports for your account, campaigns, automations, flows and transactional traffic. One consistent request grammar, one consistent response shape, across every report family.
**Language:** en
**Audience:** developer
**Search keywords:** analytics, reporting, metrics, statistics, performance, report, roas, conversions, flow, flows, flow report
**Related pages:** /platform-api/product-api/analytics/statistics, /platform-api/product-api/analytics/building-queries
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/overview/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/overview.md (Markdown)
The Analytics endpoints expose Instasent's performance data as a small set of **curated, read-only reports**. You ask for named statistics over a timeframe and get back a clean, predictable JSON document — the same response shape whether you are reporting on the whole account, a single campaign, an automation, or your transactional traffic.
There is no query language to learn and no raw data to post-process. You pick the statistics you want, optionally a timeframe and a breakdown, and the engine returns aggregated values (or a zero-filled time series) already computed for you.
## Report families
Analytics is organised into families. Each family answers a different question and lives under `/project/{project}/analytics/…`. Most families offer a **values** report (aggregate totals) and a **series** report (the same statistics bucketed over time); two of them also offer a **compare** report, and flows offer the series report only.
- [Account overview](#account-overview) - Aggregate account performance across everything you sent — the dashboard summary as one report.
- [Campaign report](#campaign-report) - Statistics for a single campaign, plus a compare report to rank several campaigns side by side.
- [Automation report](#automation-report) - Statistics for a single automation, with a built-in current-vs-previous comparison and a compare report.
- [Flow report](#flow-report) - Statistics for a single flow — the whole flow, or one version of it.
- [Transactional rollup](#transactional-rollup) - How your transactional (API / direct) traffic is performing across the project.
### Account overview
The whole-account view: terminal-state delivery, engagement, conversion, cost and suppression across all the traffic Instasent sent for you. It is the only report that accepts the **full breakdown catalog** — `group_by` over `channel`, `country`, `language`, `gender`, `segment` or `communication_type` — while its `filter` is `country` only. It also accepts a previous-period comparison. The default timeframe is the last 3 months. See the [support matrix](/platform-api/product-api/analytics/building-queries#which-report-supports-what) for what every report supports.
[`GET /project/{project}/analytics/overview` - Account overview — aggregate totals.](/platform-api/product-api/reference)
[`GET /project/{project}/analytics/overview/series` - Account overview — time series.](/platform-api/product-api/reference)
### Campaign report
Statistics for one campaign. The default report is the campaign's **lifetime**, served fast from its stored statistics; any other timeframe (or a per-channel breakdown) is computed on demand. Use the compare endpoint to rank a set of campaigns over a shared timeframe in a single call.
[`GET /project/{project}/analytics/campaign/{campaign}` - Campaign report — aggregate totals.](/platform-api/product-api/reference)
[`GET /project/{project}/analytics/campaign/{campaign}/series` - Campaign report — time series.](/platform-api/product-api/reference)
[`POST /project/{project}/analytics/campaign/compare` - Compare a set of campaigns over one timeframe.](/platform-api/product-api/reference)
### Automation report
Statistics for one automation. The default report is the last 30 days **with a previous-period comparison** already attached, so you can see the trend without a second call. Use the compare endpoint to rank several automations together.
[`GET /project/{project}/analytics/automation/{automation}` - Automation report — aggregate totals.](/platform-api/product-api/reference)
[`GET /project/{project}/analytics/automation/{automation}/series` - Automation report — time series.](/platform-api/product-api/reference)
[`POST /project/{project}/analytics/automation/compare` - Compare a set of automations over one timeframe.](/platform-api/product-api/reference)
### Flow report
Statistics for one flow, bucketed over time. A flow is built in the dashboard and read through the API, so the family comes with its own discovery endpoints — the flow listing and, under it, the version listing that hands you the ids. It is the only family with a **`version` filter**: left out, the report is the flow total across every version; passed, every statistic is scoped to the one version, which is how an A/B version is measured against the live one. Its window is clamped to the flow's lifetime and the report is always computed on demand.
[`GET /project/{project}/analytics/flow/{flow}/series` - Flow report — time series.](/platform-api/product-api/reference)
Full detail, discovery endpoints and the version model: [Flow reports](/platform-api/product-api/analytics/flows).
### Transactional rollup
A project-level rollup of all your direct / transactional (API-sent) traffic over a timeframe — the "how is my transactional traffic performing?" view. It is always computed on demand and accepts a previous-period comparison. The default timeframe is the last 30 days with a comparison.
[`GET /project/{project}/analytics/direct` - Transactional rollup — aggregate totals.](/platform-api/product-api/reference)
[`GET /project/{project}/analytics/direct/series` - Transactional rollup — time series.](/platform-api/product-api/reference)
## How the reports work
A few principles hold across every family:
- **You choose the statistics.** The required `statistics` parameter is a comma-separated list of names from a fixed catalog. Each family exposes the subset that makes sense for it. See [Statistics catalog](/platform-api/product-api/analytics/statistics).
- **One request grammar.** Timeframe, timezone, currency, breakdowns and comparison are expressed the same way everywhere. See [Building queries](/platform-api/product-api/analytics/building-queries).
- **One response shape.** Every response wraps the report under a top-level `entity` key (the Product API convention). Inside it, values reports return a `statistics` object; series reports return a shared `date_times` axis with index-aligned arrays. Every report echoes the resolved `timeframe`, `currency` and a `freshness` block. See [Reading results](/platform-api/product-api/analytics/reading-results).
- **Read scopes only.** A report covers the families your token is allowed to read. The overview, for example, reports the campaign / automation / transactional families your token has read access to.
> **Note**: Delivery figures reflect **terminal-state** delivery only — delivered, failed or expired. Very recent traffic can under-report for a short window until delivery receipts arrive. The account overview never reports in-transit or `sent` volume; use a per-campaign or per-automation report when you need full dispatch volume.
These reports measure **the performance of what Instasent sent** for you. That framing matters for the suppression statistics in particular: `unsubscribes` counts only opt-outs your Instasent messaging drove, not your audience's total current unsubscribe state. See the [suppression statistics](/platform-api/product-api/analytics/statistics#suppression) for exactly what is counted and what is excluded.
## Limitations
The account overview reflects **what has reached a final state** — delivered, failed or expired — not everything that was just dispatched. It does not report messages still in transit.
Because of that, the **most recent window (up to \~48 hours)** is intrinsically partial and **under-reports**: recent volumes and cost are **approximate** until the traffic finishes settling, then **converge** to their true totals. This is why overview cost is reported as the `cost_approx` flavour — see the [approximate statistics](/platform-api/product-api/analytics/statistics#approximate-statistics) explanation.
> **Warning**: For a **billing-accurate cost** and full dispatch volume — including messages still in transit — use the per-entity [campaign or automation report](/platform-api/product-api/analytics/reading-results), whose figures are exact and match billing.
## Where to go next
- [Statistics catalog](/platform-api/product-api/analytics/statistics) - Every statistic you can request, what it means, and which families expose it.
- [Building queries](/platform-api/product-api/analytics/building-queries) - Timeframes, timezone, currency, breakdowns and comparisons.
- [Conversions & revenue](/platform-api/product-api/analytics/conversions) - How revenue statistics are computed and scoped.
- [Reading results](/platform-api/product-api/analytics/reading-results) - The response shapes, freshness, warnings and errors.
- [Flow reports](/platform-api/product-api/analytics/flows) - Discovering flows and their versions, and scoping a report to one version.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/statistics
# Statistics catalog
Every statistic you can request from the Analytics endpoints — delivery, engagement, conversion, cost and suppression — what each one means, which report families expose it, and which figures are approximate.
**Language:** en
**Audience:** developer
**Search keywords:** statistics, metrics, delivered, delivery rate, clicks, click rate, ctr, opens, open, open rate, open_rate, click_to_open_rate, click to open rate, read receipt, read receipts, conversions, roas, cost, unsubscribes, triggers
**Related pages:** /platform-api/product-api/analytics/overview, /platform-api/product-api/analytics/conversions
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/statistics/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/statistics.md (Markdown)
Every Analytics report is driven by the required `statistics` parameter: a comma-separated list of statistic names drawn from the catalog below. You only get the statistics you ask for, in the order that matters — the **first** statistic in your list also drives ordering for grouped and compare reports.
Each report family exposes the subset of the catalog that makes sense for it. Requesting a statistic a family does not support returns a `400` with a clear hint (see [Reading results](/platform-api/product-api/analytics/reading-results#errors)). The [flow report](/platform-api/product-api/analytics/flows) exposes the same subset as the automation report, and can scope any of it to a single flow version.
## How to request statistics
`statistics` is a comma-separated list. Pick the columns you actually need — there is no "all" shortcut, and a tighter list is cheaper to compute.
#### url
```text
GET /project/{project}/analytics/overview
?statistics=delivered,delivery_rate,click_rate,conversions,conversion_value,roas
&timeframe=last_30_days
```
## Delivery
| Statistic | Meaning |
| --------------- | ----------------------------------------------------- |
| `recipients` | Distinct contacts targeted. |
| `sent` | Messages dispatched. |
| `delivered` | Messages confirmed delivered. |
| `failed` | Messages that failed delivery. |
| `expired` | Messages that expired before delivery. |
| `delivery_rate` | `delivered` over the relevant denominator, as a rate. |
| `failed_rate` | `failed` as a rate. |
> **Note**: The account overview reports terminal-state delivery only (`delivered`, `failed`, `expired`). `sent` and in-transit volume are not available there — request a per-campaign or per-automation report when you need full dispatch volume.
## Engagement
| Statistic | Meaning |
| ---------------------------- | --------------------------------------------------------------- |
| `clicks` | Total link clicks. |
| `clicks_first` | First click of a link by a contact. |
| `clicks_returning` | Repeat clicks after the first. |
| `contacts_clicked_approx` | Distinct contacts who clicked (estimate). |
| `click_rate` | Click rate over the relevant denominator. |
| `ctr` | Click-through rate. |
| `contacts_click_rate_approx` | Distinct-clicker rate (estimate). |
| `opens` | Message displays / read receipts. Channel-specific — see below. |
| `open_rate` | `opens` over `delivered`. |
| `click_to_open_rate` | `clicks_first` over `opens`. |
> **Tip**: On messages that carry several links, `clicks_first` can over-count distinct clickers because it counts the first click per link. When you specifically want "how many people clicked", prefer `contacts_clicked_approx`.
### Open statistics are channel-specific
`opens`, `open_rate` and `click_to_open_rate` are available on the **campaign, automation and flow** reports only (not overview, direct or segment), and they depend on the channel having an open signal. Today only **RCS** reports them; email opens will arrive when the Email channel ships.
On a channel with no open signal — SMS — these statistics are **omitted from the response, not returned as `0`**. Absence means "not measurable on this channel", not "nobody opened": a missing `opens` key is the only correct reading. Do not treat it as zero, and do not derive an `open_rate` from a missing `opens`.
> **Warning**: On a multi-channel report (for example an RCS + SMS fallback campaign), `opens` reflects the **RCS leg only** — there is no open signal on the SMS leg to fold in, so a top-level `open_rate` would silently blend a measurable channel with an unmeasurable one. Read the per-channel `open_rate` with `group_by=channel` rather than the blended figure.
## Conversion & revenue
| Statistic | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `conversions` | Number of attributed conversions. |
| `conversion_value` | Monetary value of those conversions. |
| `conversion_rate` | Conversions over the relevant denominator. |
| `average_order_value` | Average value per conversion. |
| `revenue_per_recipient` | Conversion value per recipient. |
| `profit` | Margin. Configured by default; amounts depend on a margin value on your conversion events, otherwise `0`. |
Both `sales` (revenue) and `profit` (margin) are configured automatically for every project — revenue tracking works with no setup. A revenue statistic comes back `null` only when the project's conversion configuration omits the requested scope (for example a custom config without `profit`); that case carries a `conversion_scope_unconfigured` warning, not an error. A configured scope with nothing to report — including `profit` when your conversion events carry no margin value — returns `0`, not `null`. See [Conversions & revenue](/platform-api/product-api/analytics/conversions).
## Cost & ROI
| Statistic | Meaning |
| ------------------------- | ------------------------------------------------------------------------------ |
| `cost` | Send spend over the window. |
| `cost_approx` | Send spend computed from events (used where an exact figure is not available). |
| `cost_per_message` | Spend per message. |
| `cost_per_message_approx` | Approximate spend per message. |
| `roas` | Return on ad spend (conversion value over cost). Dimensionless. |
| `roas_approx` | Approximate ROAS. Dimensionless. |
| `epm` | Earnings per message. Dimensionless. |
| `epm_approx` | Approximate earnings per message. Dimensionless. |
## Suppression
| Statistic | Meaning |
| ------------------ | ---------------------------------------------------------------- |
| `unsubscribes` | Opt-outs **driven by your Instasent messaging** over the window. |
| `unsubscribe_rate` | Rate of those messaging-driven opt-outs. |
These statistics measure **messaging performance**: how many people your Instasent sends pushed to opt out. An opt-out counts here only when it is attributable to a message you sent through Instasent — a campaign, an automation, or a direct / transactional send — whether the contact replied STOP, used an unsubscribe link, or otherwise opted out off the back of that send. On a campaign or automation report, the count is scoped to opt-outs driven by **that** campaign or automation.
Opt-outs that did **not** come from an Instasent send are **excluded by design** — they are not a reflection of how your messaging performed:
- suppressions pushed through the API,
- consent changes you make by hand on a contact, list or audience,
- consent synced in from external platforms (for example Klaviyo, ActiveCampaign or Mailchimp).
> **Note**: `unsubscribes` answers "how many people did this messaging drive to opt out?", not "how many contacts are currently unsubscribed?". The current consent state of your audience — across every cause, including API, manual and imported opt-outs — is an audience and consent concern, separate from these performance reports.
## Automation only
| Statistic | Meaning |
| ---------- | ----------------------------------------- |
| `triggers` | Times the automation fired in the window. |
Available on the automation report family only.
## Approximate statistics
Any statistic whose name ends in `_approx` is **not exact**, and the suffix is part of the contract — anything derived from an approximate figure stays approximate.
There are two reasons a statistic is marked approximate:
- **Distinct-count estimates** (for example `contacts_clicked_approx`) use an efficient estimator that trades a small margin of error (roughly 1–2%) for speed at scale. As a consequence, a distinct-contact figure is **not summable across time buckets** in a series — adding the per-day values does not give you the period total. See [Reading results](/platform-api/product-api/analytics/reading-results#warnings).
- **Event-based figures** can under-report the most recent window (about 48 hours) until delivery receipts arrive, then settle to their final value.
> **Warning**: Treat `_approx` statistics as indicative, not as figures to reconcile to the cent or to sum across buckets. When you need an exact count, use the exact counterpart (`recipients`, per-entity `sent`, per-entity `cost`).
## Next
- [Building queries](/platform-api/product-api/analytics/building-queries) - Combine these statistics with timeframes, breakdowns and comparisons.
- [Conversions & revenue](/platform-api/product-api/analytics/conversions) - How the conversion and revenue statistics are scoped and computed.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/building-queries
# Building queries
Shape an Analytics request: choose a timeframe (predefined or custom), set the timezone and output currency, break results down with group_by, narrow them with bounded filters, and attach a previous-period comparison.
**Language:** en
**Audience:** developer
**Search keywords:** timeframe, interval, timezone, currency, group_by, group by, breakdown, breakdowns, filter, filters, filter channel, filter country, channel, country, language, gender, segment, communication_type, communication type, sender, country code, ISO 3166, alpha-2, uppercase, case-insensitive, unknown country, no other bucket, other bucket, overflow, top 20, top buckets, account overview, campaign report, automation report, transactional rollup, support matrix, supported group_by, supported filter, per report, cross-cut, cross cut, countries within a channel, channels within a country, single dimension, one dimension, compare, previous period, query parameters, flow report, filter version, flow version
**Related pages:** /platform-api/product-api/analytics/statistics, /platform-api/product-api/analytics/reading-results
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/building-queries/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/building-queries.md (Markdown)
Every Analytics report shares the same request grammar. Beyond the required `statistics` list ([Statistics catalog](/platform-api/product-api/analytics/statistics)), a handful of optional parameters control the window, the units, the breakdown and the comparison. They behave identically across families, so once you know them you can read any report.
## Timeframe
Pick a window in one of two ways. Use a **predefined key** for the common cases, or **`start` + `end`** for an arbitrary range.
### Predefined keys
Pass `timeframe` with one of:
`today`, `yesterday`, `this_week`, `last_week`, `last_7_days`, `this_month`, `last_month`, `last_30_days`, `last_90_days`, `last_3_months`, `last_12_months`, `this_year`, `last_year`.
If you omit `timeframe`, each family applies a sensible default that matches its fastest path (the account overview defaults to the last 3 months; campaigns to their lifetime; automations, flows and the transactional rollup to the last 30 days).
#### url
```text
GET /project/{project}/analytics/overview
?statistics=delivered,click_rate
&timeframe=last_30_days
```
### Custom range
Provide both `start` and `end` as ISO 8601 timestamps. When both are present they define the window and override `timeframe`.
#### url
```text
GET /project/{project}/analytics/overview
?statistics=delivered,click_rate
&start=2026-05-01T00:00:00+02:00
&end=2026-05-30T23:59:59+02:00
```
Windows are snapped to whole-hour boundaries in the resolved timezone (start snapped down, end snapped up). When snapping changes the bounds you asked for, the response carries a warning and echoes the actual window in its `timeframe` block.
> **Note**: The maximum span depends on your plan. Over-long requests are **clamped** to the allowed maximum and flagged with a warning rather than rejected, so you always get a report back.
## Interval (series only)
Series reports bucket the window over time. Set `interval` to one of `hour`, `day`, `week`, `month`. The default is `day` regardless of how long the window is.
If your window and interval would produce too many buckets, the server automatically coarsens to the next grain and tells you via a warning; the report always echoes the **effective** interval it used. If even the coarsest grain does not fit, the request is rejected with a clear error asking you to narrow the window.
## Timezone
Set `timezone` to any IANA zone (for example `Europe/Madrid`). It governs day, week and month boundaries and bucketing. The default is your **project's timezone** — never UTC and never the caller's local time. The resolved timezone is echoed in every response.
## Currency
Set `currency` to an ISO 4217 code to receive every monetary statistic in that currency. The response echoes the output `currency` and the `source_currency` the data was stored in. Dimensionless statistics (`roas`, `epm`) are never converted.
## Breakdowns with `group_by`
`group_by` splits a report into buckets along **exactly one dimension**. It is strictly single-dimension: multi-dimensional, nested or two-dimensional `group_by` is **not supported and never will be**. A request that passes more than one dimension is rejected with `too_many_group_by_dimensions`. There is no nested keying, no second dimension stacked under the first, and no per-outer roll-up — pass a single dimension.
The breakdown vocabulary is `channel`, `country`, `language`, `gender`, `segment` and `communication_type`. **Which of these a given report accepts varies by report** — see the [support matrix](#which-report-supports-what) below.
| Dimension | Cardinality |
| -------------------- | ----------------------------------------- |
| `channel` | Closed enum — every bucket returned |
| `communication_type` | Closed enum — every bucket returned |
| `gender` | Closed enum — every bucket returned |
| `country` | Open — top 20 buckets, no overflow bucket |
| `language` | Open — top 20 buckets, no overflow bucket |
| `segment` | Open — top 20 buckets, no overflow bucket |
Closed-enum dimensions return **every** bucket. Open-cardinality dimensions return the **top 20** buckets ordered by your first requested statistic — there is **no synthetic `other` bucket**: the tail beyond the top 20 is simply not returned, never rolled up into an overflow bucket.
When you group by `country`, the bucket keys are the uppercase ISO 3166-1 alpha-2 code (`ES`, `FR`), plus an `unknown` bucket for recipients whose country could not be resolved.
To look at one dimension within another (countries within RCS, channels within Spain), don't stack two dimensions — pair a single `group_by` with a `filter`. See [Cross-cutting two dimensions](#cross-cutting-two-dimensions).
When you group, the response switches to the grouped shape: a values report keys `statistics` by group, a series report keys `series` by group, and both add a `totals_per_group` roll-up plus a `group_order` ranking — see [Grouped values](/platform-api/product-api/analytics/reading-results#grouped-values) and [Grouped series](/platform-api/product-api/analytics/reading-results#grouped-series).
#### url
```text
GET /project/{project}/analytics/overview/series
?statistics=delivered,clicks_first
&timeframe=last_30_days
&group_by=channel
```
## Filters
`filter` narrows a report to a subset before aggregating. There is **no raw query language** — only a closed set of keys, each validated against a fixed enum. Use bracket syntax:
```text
?filter[channel]=sms&filter[country]=ES
```
**The two general filter keys are `channel` and `country`.** Every other breakdown dimension — `language`, `gender`, `segment`, `communication_type` and `sender` — is **`group_by`-only and is never a filter**; passing it as a filter key returns a `400`, as does any value outside the closed enum. One report adds a third key of its own: the flow report accepts `version` (see [Flow reports](/platform-api/product-api/analytics/flows#totals-versus-one-version)), which no other family does and which is never a `group_by` dimension.
Which filters a given report honors **varies by report** — see the [support matrix](#which-report-supports-what). A report rejects any filter key it does not support.
Country filter values are the ISO 3166-1 alpha-2 code and are **case-insensitive** on input — `filter[country]=ES` and `filter[country]=es` are equivalent. Group-by `country` bucket *keys*, by contrast, are always returned uppercase (`ES`), with an `unknown` bucket for unresolved recipients.
### Cross-cutting two dimensions
Because `group_by` is single-dimension, you look at one dimension *within* another by **pairing a single `group_by` with a `filter` on the other dimension** — not by stacking two `group_by` values. This works **on any report that supports both that `group_by` and that `filter`** (see the [support matrix](#which-report-supports-what) — for example, the campaign and automation reports honor both `channel` and `country`):
| Goal | Request |
| --------------------- | ------------------------------------------ |
| Countries within RCS | `filter[channel]=rcs` + `group_by=country` |
| Channels within Spain | `filter[country]=ES` + `group_by=channel` |
| RCS totals only | `filter[channel]=rcs` (no `group_by`) |
| Spain totals only | `filter[country]=ES` (no `group_by`) |
#### url
```text
GET /project/{project}/analytics/campaign/{campaign}
?statistics=delivered,clicks_first
&filter[channel]=rcs
&group_by=country
```
Filtering and grouping on the **same** dimension is redundant and rejected (`filter[channel]=rcs` + `group_by=channel`).
## Which report supports what
`group_by` and `filter` are expressed identically everywhere, but **each report accepts its own subset** — and each report rejects any dimension it does not support. This matrix is the authoritative reference; a report's own reference page repeats its row.
| Report | `group_by` | `filter` |
| ----------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------- |
| Account overview — values & series | `channel`, `country`, `language`, `gender`, `segment`, `communication_type` | `country` |
| Campaign report — values | `channel`, `country` | `channel`, `country` |
| Campaign report — series | `channel`, `country` | `channel`, `country` |
| Automation report — values | `channel`, `country` | `channel`, `country` |
| Automation report — series | `channel`, `country` | `channel`, `country` |
| Flow report — series | `channel`, `country` | `channel`, `country`, `version` |
| Direct / transactional rollup — values & series | `channel`, `country` | `channel`, `country` |
A few consequences worth calling out:
- The **account overview** is the only report that accepts the full breakdown catalog, but it filters by `country` only.
- The **campaign, automation and transactional rollup** reports all behave the same on both their **values and series**: they break down and filter by `channel` and `country`, so the cross-cut works on every one of them (e.g. `filter[country]=ES` + `group_by=channel`, or `filter[channel]=rcs` + `group_by=country`).
- The **flow report** has no values shape — it is a series only — and it is the one report with a third filter key, `version`. It combines with the others, so `filter[version]=…` + `group_by=channel` gives you one version's per-channel series.
- The transactional rollup's `group_by=channel` series is the per-leg series of a fallback chain.
## Comparison
On **values** reports, set `compare_to=previous_period` to attach a `comparison` block: the natural "before" window, the same statistics, and the per-statistic change (absolute and percentage).
- Accepted for the account overview, automations and the transactional rollup.
- **Rejected for standard campaigns** — a campaign is a one-shot send with no previous period. To compare campaigns, use the campaign **compare** endpoint instead.
- **Not applicable to flows** — the flow family is a series report only, and comparison is a values-report feature. To weigh one flow version against another, scope two series calls with `filter[version]` — see [Comparing two versions](/platform-api/product-api/analytics/flows#comparing-two-versions).
```text
?compare_to=previous_period
```
See [Reading results](/platform-api/product-api/analytics/reading-results#comparison) for the comparison shape, and the [campaign / automation compare reports](/platform-api/product-api/analytics/reading-results#compare-reports) for ranking several entities at once.
## Next
- [Reading results](/platform-api/product-api/analytics/reading-results) - Response shapes, freshness, warnings and errors.
- [Conversions & revenue](/platform-api/product-api/analytics/conversions) - How revenue statistics are scoped per project.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/conversions
# Conversions & revenue
How the Analytics revenue statistics are computed: conversion scopes, how revenue tracking is configured, when a scope comes back null versus zero, and currency.
**Language:** en
**Audience:** developer
**Search keywords:** conversions, revenue, conversion scope, sales, profit, roas, conversion value, average order value
**Related pages:** /platform-api/product-api/analytics/statistics, /platform-api/product-api/analytics/building-queries
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/conversions/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/conversions.md (Markdown)
Several statistics are monetary — `conversion_value`, `conversions`, `average_order_value`, `revenue_per_recipient`, `roas`, `profit` and their approximate variants. Revenue tracking works **out of the box**: by default the platform configures your project to track both revenue and margin automatically, so reports return real figures with zero setup. This page explains how revenue tracking is configured, which scope to report against, and how to tell a `null` (scope not configured) apart from a `0` (configured, but nothing to count).
## Conversion scope
A report attributes revenue through a **conversion scope**, selected with the `conversion_scope` parameter:
| Scope | Meaning |
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sales` | Revenue. The default scope, configured automatically for every project. |
| `profit` | Margin. Also configured automatically for every project. Profit amounts depend on a margin value being present on your conversion events; if your events carry no margin, profit comes back as `0`. |
The scope is the only conversion choice the caller makes. **What counts as a conversion, which value is attributed and which traffic contributes are part of your project's configuration**, not request parameters. This keeps revenue numbers consistent no matter who runs the report or from which tool.
## How revenue tracking is configured
You do not set up conversions on a per-request basis. By default, the platform synthesises a **default conversion configuration** for your project automatically, and that default covers **both** `sales` and `profit`. So both revenue and margin are tracked out of the box — neither scope is `null` by default.
A project **may** instead define its own **custom conversion configuration** in its settings — which events count as a conversion, which field carries the conversion value, and which datasources contribute. That is project configuration, decided once in the dashboard; it is never a request parameter, and the API caller never sees those internals. A custom configuration only includes the scopes you define on it: if a custom configuration leaves a scope out, that scope is simply not tracked (there is no fall-back to the default).
#### url
```text
GET /project/{project}/analytics/overview
?statistics=conversions,conversion_value,roas
&conversion_scope=sales
&timeframe=last_30_days
```
The resolved scope is echoed back in the response as `conversion_scope` whenever monetary statistics were requested.
## When does a revenue statistic come back null?
A revenue statistic is `null` for exactly one reason: **the project's conversion configuration does not include the requested scope.** By default this does not happen — the default configuration covers both `sales` and `profit`, so both come back as numbers. It only occurs when a project has switched to a custom conversion configuration that leaves the requested scope out (for example a custom config without `profit`). When that happens, the report still **succeeds**, the missing scope's statistics are `null` — not an error — and the response carries a `conversion_scope_unconfigured` warning so you can tell why.
This is different from a scope being configured but having nothing to report:
- **`null` — the scope is not configured.** The project's configuration omits it. Check the `warnings` array (see [Reading results](/platform-api/product-api/analytics/reading-results#warnings)) for the `conversion_scope_unconfigured` code.
- **`0` / `0.00` — the scope is configured, but there is no value to report.** Either there were no conversions in the window, or the value is genuinely zero. A common case: `profit` is configured by default, but profit amounts depend on a margin value being present on your conversion events; if your events don't carry one, profit silently returns `0` — not `null`, and no warning.
> **Tip**: `null` means "this scope is not part of your project's configuration"; `0` means "configured, but no conversions or no value in the window". So a `profit` of `0` is not an error and not a misconfiguration — it usually just means your conversion events don't carry a margin value. Only a `null` points at the configuration, and it always comes with the `conversion_scope_unconfigured` warning.
## Currency
Revenue is reported in the output `currency` you request (ISO 4217), defaulting to the project's stored currency. The response echoes both the output `currency` and the `source_currency` the data was stored in. Ratio statistics — `roas` and `epm` — are dimensionless and are never converted.
## Revenue statistics at a glance
| Statistic | Meaning |
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
| `conversions` | Count of attributed conversions in the window. |
| `conversion_value` | Total monetary value of those conversions. |
| `conversion_rate` | Conversions over the relevant denominator. |
| `average_order_value` | Value per conversion. |
| `revenue_per_recipient` | Conversion value divided by recipients. |
| `roas` | Conversion value over send cost (dimensionless). |
| `epm` | Earnings per message (dimensionless). |
| `profit` | Margin. Configured by default; amounts depend on a margin value on your conversion events, otherwise `0`. |
See the full [Statistics catalog](/platform-api/product-api/analytics/statistics) for the delivery, engagement and cost statistics you can combine with these.
## Next
- [Building queries](/platform-api/product-api/analytics/building-queries) - Timeframes, currency, breakdowns and comparisons.
- [Reading results](/platform-api/product-api/analytics/reading-results) - Response shapes, freshness, warnings and errors.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/reading-results
# Reading results
The Analytics response shapes: values reports, time series, grouped series and compare reports, plus the freshness block, the comparison block, warnings and the error model.
**Language:** en
**Audience:** developer
**Search keywords:** response, values, series, date_times, grouped, grouped values, group_by, totals_per_group, group_order, single dimension, cross-cut, compare, freshness, warnings, errors, comparison
**Related pages:** /platform-api/product-api/analytics/building-queries, /platform-api/product-api/analytics/statistics
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/reading-results/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/reading-results.md (Markdown)
Every Analytics report returns the same family of response shapes. Once you can read one, you can read them all: each carries the resolved `timeframe`, the output `currency`, a `freshness` block describing how fresh the numbers are, and a `warnings` array. This page walks through each shape.
> **Note**: Like every Product API endpoint, Analytics wraps its payload in a top-level `entity` key. The report itself — the `timeframe`, `statistics`, `series`, `comparison`, `freshness` and so on — always lives under `entity`. The examples below show the full response, `entity` wrapper included; when this page refers to a field such as `statistics` or `freshness`, it means `entity.statistics`, `entity.freshness`, and so on.
## Values report
A **values** report returns aggregate totals over the resolved window in a `statistics` object. Counts are integers, rates are fractions (4 decimals), monetary amounts have 2 decimals, and ratios like `roas` are dimensionless. Empty windows return explicit zeros rather than omitting keys. The only legitimate `null` is a revenue statistic for a scope the project's conversion configuration does not include (it always comes with a `conversion_scope_unconfigured` warning); a configured scope with no conversions or no value returns `0`.
```json
{
"entity": {
"resource": { "type": "overview" },
"timeframe": {
"key": "last_3_months",
"start": "2026-03-01T00:00:00+01:00",
"end": "2026-05-29T23:59:59+02:00",
"timezone": "Europe/Madrid"
},
"conversion_scope": "sales",
"currency": "EUR",
"source_currency": "EUR",
"statistics": {
"delivered": 48210,
"failed": 1203,
"delivery_rate": 0.9757,
"clicks_first": 5120,
"click_rate": 0.1062,
"conversions": 612,
"conversion_value": 38420.55,
"roas": 15.94,
"unsubscribes": 88,
"unsubscribe_rate": 0.0018
},
"freshness": {
"computed_at": "2026-05-29T10:00:00Z",
"source": "cache",
"stale": false,
"ttl_seconds_remaining": 1842
},
"warnings": []
}
}
```
For per-entity reports (a single campaign, automation or flow), `resource` carries that entity's public metadata — id, name, status, channel, the fallback `channel_chain`, option count, audience size and the relevant timestamps, whichever of those apply to the entity — so you can describe the report without a second lookup.
### Grouped values
When you add `group_by`, the values response switches to the grouped shape — and this works on every family that accepts a breakdown, the per-entity campaign and automation reports and the transactional rollup included, not just the account overview. `group_by` is strictly single-dimension: `statistics` becomes a map keyed by each group value, where each value is a **full statistics object** (the same fields the flat report returns). Alongside it, `totals_per_group` carries the cross-group roll-up — the same totals the flat report would return — `group_order` (an array of group keys in rank order) makes the ranking explicit, and `group_by` echoes back the one dimension as a single-element array.
```json
{
"entity": {
"resource": { "type": "campaign", "id": "6627f1a2b3c4d5e6f7a8b9c0", "name": "Spring sale" },
"timeframe": {
"key": null,
"start": "2026-04-12T00:00:00+02:00",
"end": "2026-05-29T23:59:59+02:00",
"timezone": "Europe/Madrid"
},
"conversion_scope": "sales",
"currency": "EUR",
"source_currency": "EUR",
"statistics": {
"sms": { "delivered": 5, "conversions": 2, "conversion_value": 236.80 },
"rcs": { "delivered": 3, "conversions": 1, "conversion_value": 150.00 }
},
"totals_per_group": { "delivered": 8, "conversions": 3, "conversion_value": 386.80 },
"group_order": ["sms", "rcs"],
"group_by": ["channel"],
"freshness": { "computed_at": "2026-05-28T10:14:33Z", "source": "entity_stats", "stale": false, "ttl_seconds_remaining": null },
"warnings": []
}
}
```
Open-cardinality dimensions (`country`, `language`, `segment`) return only their **top 20** groups ordered by your first statistic, with no `other` overflow bucket — the same rule the grouped series follows. A request with two or more `group_by` dimensions is rejected with `too_many_group_by_dimensions`; to look at one dimension within another, pair a single `group_by` with a `filter` — see [Cross-cutting two dimensions](/platform-api/product-api/analytics/building-queries#cross-cutting-two-dimensions). For which `group_by` each report accepts, see the [support matrix](/platform-api/product-api/analytics/building-queries#which-report-supports-what).
## Time series
A **series** report returns a single continuous, zero-filled `date_times` axis plus one array per statistic, each the same length as `date_times` and index-aligned. There are no gaps to interpolate — empty buckets are explicit zeros. The report echoes the **effective** `interval`.
```json
{
"entity": {
"resource": { "type": "campaign", "id": "6627f1a2b3c4d5e6f7a8b9c0", "name": "Spring sale" },
"timeframe": {
"key": "last_30_days",
"start": "2026-05-01T00:00:00+02:00",
"end": "2026-05-30T23:59:59+02:00",
"timezone": "Europe/Madrid"
},
"currency": "EUR",
"source_currency": "EUR",
"interval": "day",
"date_times": ["2026-05-01", "2026-05-02", "2026-05-03"],
"series": {
"delivered": [0, 8, 0],
"clicks_first": [0, 3, 0],
"conversions": [0, 3, 0],
"conversion_value": [0, 386.80, 0]
},
"freshness": { "computed_at": "2026-05-28T10:15:02Z", "source": "live", "stale": false, "ttl_seconds_remaining": null },
"warnings": []
}
}
```
### Grouped series
When you add `group_by`, the series response switches to the grouped shape. `group_by` is strictly single-dimension: `series` is keyed by each group value (a flat map, never nested under a second dimension), `totals_per_group` rolls them up across groups (the same totals the flat report returns), and `group_order` (an array of group keys in rank order) makes the ranking explicit. The `group_by` field echoes back the one dimension as a single-element array.
```json
{
"entity": {
"timeframe": {
"key": "last_30_days",
"start": "2026-05-01T00:00:00+02:00",
"end": "2026-05-30T23:59:59+02:00",
"timezone": "Europe/Madrid"
},
"currency": "EUR",
"source_currency": "EUR",
"interval": "day",
"date_times": ["2026-05-01", "2026-05-02", "2026-05-03"],
"group_by": ["channel"],
"totals_per_group": {
"delivered": [0, 1200, 1800],
"clicks_first": [0, 210, 350]
},
"series": {
"sms": {
"delivered": [0, 800, 1200],
"clicks_first": [0, 120, 200]
},
"rcs": {
"delivered": [0, 400, 600],
"clicks_first": [0, 90, 150]
}
},
"group_order": ["sms", "rcs"],
"freshness": { "computed_at": "2026-05-29T10:15:02Z", "source": "live", "stale": false, "ttl_seconds_remaining": null },
"warnings": []
}
}
```
A request with two or more `group_by` dimensions is rejected with `too_many_group_by_dimensions` — there is no nested keying and no per-outer roll-up, and multi-dimensional grouping will not be added. To look at one dimension within another, pair a single `group_by` with a `filter` — see [Cross-cutting two dimensions](/platform-api/product-api/analytics/building-queries#cross-cutting-two-dimensions). Open-cardinality dimensions (`country`, `language`, `segment`) return only their **top 20** buckets ordered by your first statistic; there is **no `other` overflow bucket** — the tail is simply not keyed. For which `group_by` each report accepts, see the [support matrix](/platform-api/product-api/analytics/building-queries#which-report-supports-what).
## Comparison
When you request `compare_to=previous_period` on a values report, the response adds a `comparison` block alongside `statistics` under `entity`: the resolved previous window, the same statistics for it, and a `delta` object with the absolute change per statistic plus a `_pct` fractional change (`null` when the previous value was zero). The fragment below shows just that block — in a full response it sits inside `entity`, next to `statistics`.
```json
"comparison": {
"timeframe": {
"start": "2026-03-31T00:00:00+02:00",
"end": "2026-04-29T23:59:59+02:00"
},
"statistics": { "conversion_value": 7420.10 },
"delta": { "conversion_value": 1490.20, "conversion_value_pct": 0.2008 }
}
```
## Compare reports
The campaign and automation **compare** endpoints return one aligned values report per entity over a shared timeframe, ordered descending by your first requested statistic, plus a `summary` that names the best and worst entity for each statistic.
```json
{
"entity": {
"timeframe": {
"key": "last_90_days",
"start": "2026-03-01T00:00:00+01:00",
"end": "2026-05-29T23:59:59+02:00",
"timezone": "Europe/Madrid"
},
"conversion_scope": "sales",
"currency": "EUR",
"source_currency": "EUR",
"reports": [
{
"resource": { "type": "campaign", "id": "6627f1a2b3c4d5e6f7a8b9c1", "name": "Black Friday", "status": "sent" },
"statistics": { "delivery_rate": 0.9120, "click_rate": 0.4123, "conversion_value": 542.15, "roas": 980.45 }
},
{
"resource": { "type": "campaign", "id": "6627f1a2b3c4d5e6f7a8b9c0", "name": "Spring sale", "status": "sent" },
"statistics": { "delivery_rate": 0.8889, "click_rate": 0.3750, "conversion_value": 386.80, "roas": 1175.69 }
}
],
"summary": {
"best": { "delivery_rate": "6627f1a2b3c4d5e6f7a8b9c1", "roas": "6627f1a2b3c4d5e6f7a8b9c0" },
"worst": { "delivery_rate": "6627f1a2b3c4d5e6f7a8b9c0", "roas": "6627f1a2b3c4d5e6f7a8b9c1" }
},
"freshness": { "computed_at": "2026-05-29T11:02:14Z", "source": "live", "stale": false, "ttl_seconds_remaining": null },
"warnings": []
}
}
```
The compare set is posted in the request body, alongside the statistics, timeframe and conversion scope (the request body is not wrapped — only responses carry the `entity` envelope):
#### json
```json
{
"campaigns": [
"6627f1a2b3c4d5e6f7a8b9c0",
"6627f1a2b3c4d5e6f7a8b9c1",
"6627f1a2b3c4d5e6f7a8b9c2"
],
"statistics": ["delivery_rate", "click_rate", "conversion_value", "roas"],
"timeframe": "last_90_days",
"conversion_scope": "sales"
}
```
The number of entities you can compare in one call scales with your plan.
## Freshness
Every response carries a `freshness` block so you can render "data as of …" and decide whether to wait for a fresher value:
| Field | Meaning |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `computed_at` | When the numbers were produced (for a cached value, the time it was written). |
| `source` | Where the numbers came from: `cache`, `live` (freshly computed), or `entity_stats` (a campaign's or automation's stored statistics). |
| `stale` | `true` when the value is older than the family expects. |
| `ttl_seconds_remaining` | Seconds until a cached value expires; `null` for non-cached sources. |
## Warnings
The `warnings` array carries **benign metadata only** — never a swallowed failure. Analytics is fail-closed: any real problem aborts the whole call with an error (below). A warning means the report succeeded but something about your request was adjusted or is worth knowing. Each warning has a stable `code` and a human-readable `message` you can show to an end user.
| Code | What it tells you |
| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bounds_clamped` | Your window exceeded the plan maximum and was clamped. |
| `interval_coarsened` | The series interval was widened to keep the bucket count in range; the effective interval is in the response. |
| `timeframe_snapped_to_hour` | Your window was snapped to whole-hour boundaries. |
| `timeframe_clamped_to_entity_lifetime` | A per-entity window was trimmed to the entity's lifetime. |
| `conversion_scope_unconfigured` | The requested scope is not part of the project's conversion configuration, so that scope's statistics are `null`. By default both `sales` and `profit` are configured, so this only fires for a custom configuration that omits the scope. A configured scope with no value (for example `profit` with no margin data) returns `0` and does not raise this. |
| `retention_truncated` / `partial_retention_window` | Part of the requested window falls outside available history. |
| `cache_drift` | A cached total and a freshly computed total differ slightly; the figures are being reconciled. |
| `series_hll_not_summable` | A distinct-contact statistic in a series is an estimate **per bucket** and should not be summed across buckets to get a period total. |
## Errors
Analytics is **fail-closed**: on any real failure you get an error envelope and no partial results. The envelope is verbose enough to explain the cause and how to fix the request, and never leaks internals.
```json
{
"code": "unknown_statistic_for_family",
"message": "\"opens\" is not a valid statistic for a transactional report.",
"hint": "Transactional sends have no open event. Remove \"opens\" or use a campaign / automation report."
}
```
| HTTP | Code | Cause |
| ---- | --------------------------------------- | ------------------------------------------------------------------------------------------ |
| 400 | `unknown_statistic_for_family` | A statistic not supported by this family. |
| 400 | `histogram_bucket_limit_exceeded` | The series would produce too many buckets even at the coarsest interval. |
| 400 | `bound_exceeded` | A request bound was breached. |
| 400 | `invalid_currency` / `invalid_timezone` | A malformed currency code or timezone. |
| 400 | `too_many_group_by_dimensions` | More than one `group_by` dimension — `group_by` is single-dimension only. |
| 400 | `dimension_not_yet_available` | A breakdown dimension is reserved and not yet selectable. |
| 400 | `compare_to_not_supported` | `previous_period` requested for a standard campaign. |
| 401 | — | Missing or invalid token. |
| 403 | `forbidden_resource` | The token cannot read this resource, or the project is blocked. |
| 404 | `entity_not_found` | The campaign, automation or flow does not exist in this project. |
| 429 | `rate_limit_exceeded` | The endpoint limit for your plan was reached; the envelope includes a `retry_after`. |
| 500 | `analytics_timeout` | The query exceeded its time budget. Narrow the window or reduce the compare set and retry. |
> **Warning**: Because the API is fail-closed, you never have to guess whether a `0` means "no activity" or "something broke". Zeros are real zeros; failures are errors. Only `null` carries a special meaning: a revenue scope the project's conversion configuration does not include — flagged by the `conversion_scope_unconfigured` warning. A configured scope with no conversions or no value (such as `profit` with no margin data) returns `0`, not `null`.
## Next
- [Statistics catalog](/platform-api/product-api/analytics/statistics) - Every statistic and which family exposes it.
- [Building queries](/platform-api/product-api/analytics/building-queries) - Timeframes, breakdowns, filters and comparisons.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/flows
# Flow reports
Discover flows and their versions, then report on them: a flow's time series covers every version at once, or a single version when you scope it, which is how an A/B test is measured before it is promoted.
**Language:** en
**Audience:** developer
**TLDR:** Flows are published and run from the dashboard; for reporting, the API reads them. List them with GET /project/{project}/flow and get version ids from .../flow/{id}/versions. GET /project/{project}/analytics/flow/{flow}/series returns time-bucketed statistics: without filter[version] it is the flow total across all versions; with it, every statistic — deliveries, clicks, conversions, revenue — is scoped to that one version.
**Search keywords:** flow, flows, flow report, flow analytics, flow series, flow version, flow versions, version id, filter version, A/B test, AB test, split test, canary, live-test, live test, sequence, version number, promote a version, flow id, discovery, PROJECT_AUTOMATION_READ
**Related pages:** /platform-api/product-api/analytics/building-queries, /platform-api/product-api/analytics/statistics, /platform-api/product-api/flows/overview
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/flows/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/analytics/flows.md (Markdown)
Flows (Beta) are the branching journeys a project runs. Once a flow is published, the Product API lets an integration find it, enumerate its versions, and report on how it is performing. This page covers both halves: the discovery endpoints that hand you the ids, and the flow report that turns them into numbers. Creating and editing flow drafts is a separate surface, covered in [Flows](/platform-api/product-api/flows/overview).
Everything on this page is governed by a single scope, `PROJECT_AUTOMATION_READ`.
> **Note**: The API can create a flow and edit its draft (see [Flows](/platform-api/product-api/flows/overview)), but it cannot publish, activate or promote one. Publishing and version promotion happen in the dashboard, and only published versions produce the traffic this page measures.
## Finding a flow
The list endpoint is the entry point: it returns the flows of a project, newest first, and the `id` it gives you is exactly the value the report endpoint takes.
[`GET /project/{project}/flow` - List the project's flows.](/platform-api/product-api/reference)
[`GET /project/{project}/flow/{id}` - Retrieve a single flow.](/platform-api/product-api/reference)
The listing accepts the standard [Query Filter](/platform-api/further-reading/query-filter) syntax for filtering, sorting and pagination, so you can narrow it to the flows you care about:
#### url
```text
GET /project/{project}/flow
?status_eq=enabled
```
- `id` — `string`
The flow id — the value `/project/{project}/analytics/flow/{flow}/series` takes.
- `name` — `string`
The flow's name, as it reads in the dashboard.
- `status` — `string`
One of `enabled`, `disabled` or `archived`.
- `archivedAt` — `string`
When the flow was archived, or `null` while it is still active. It also bounds what the report can cover — see [Timeframe](#timeframe-and-freshness).
## Versions
A flow evolves through **versions**, and the versions endpoint is where you find the ids the report scopes to. It returns every version of the flow, newest first, archived ones included.
[`GET /project/{project}/flow/{id}/versions` - List a flow's versions.](/platform-api/product-api/reference)
Two fields carry the meaning:
- **`status` is the version's role, and it is permanent for the version's life.** A version never changes role: promoting a test version to production produces a **new** version with its own id rather than rewriting the old one. So a version id is also a stable label for the cohort its traffic belongs to.
- **`sequence` is the permanent version number** — v1, v2, v3 — assigned when a version first goes live. A `0` means unnumbered: a draft or a manual preview that has never gone live.
| `status` | What it is | In analytics |
| ----------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `live` | The production version, running for the full audience. | Counted |
| `live-test` | The A/B cohort running in parallel on a small share of the audience. Real messages to real contacts. | Counted, and included in the flow total |
| `draft` | The single editable work in progress. | Not counted |
| `test` | A manual preview run of the draft. | Not counted |
- `id` — `string`
The version id — the value `filter[version]` takes on the flow report.
- `name` — `string`
The version's name.
- `sequence` — `integer`
Permanent version number (1, 2, 3 …). `0` for an unnumbered draft or preview.
- `status` — `string`
The permanent role: `live`, `live-test`, `draft` or `test`.
- `liveAt` — `string`
When the version first went live, or `null` if it never did.
- `archivedAt` — `string`
When the version was archived, or `null` while it is still in use.
Manual previews never send to your audience, so `draft` and `test` versions produce no analytics traffic. The reports only ever carry data for `live` and `live-test` versions.
## The flow report
One report covers flows: a **series** — the statistics you ask for, bucketed over time.
[`GET /project/{project}/analytics/flow/{flow}/series` - Flow report — time series.](/platform-api/product-api/reference)
It follows the same grammar as every other family ([Building queries](/platform-api/product-api/analytics/building-queries)) and returns the same series shape ([Reading results](/platform-api/product-api/analytics/reading-results#time-series)). The specifics:
| | |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Statistics | The same catalog the automation report exposes — delivery, engagement, conversion, cost and suppression. See [Statistics catalog](/platform-api/product-api/analytics/statistics). |
| `interval` | Defaults to `day`. |
| `group_by` | `channel`, `country` — one dimension at a time. |
| `filter` | `channel`, `country`, `version`. |
| Comparison | Not applicable: `compare_to` is a values-report parameter and flows have no values report. To compare, see [Totals versus one version](#totals-versus-one-version). |
#### url
```text
GET /project/{project}/analytics/flow/{flow}/series
?statistics=delivered,clicks_first,conversions,conversion_value
&timeframe=last_30_days
&interval=day
```
`group_by=channel` gives you the per-leg series of a fallback chain, and pairing one `group_by` with a `filter` on another dimension cross-cuts the report the same way it does elsewhere — `filter[country]=ES` with `group_by=channel`, for example.
### Timeframe and freshness
A flow report is always **computed on demand** — its `freshness.source` is `live`, with no cached value to age.
The window is clamped to the flow's own lifetime: from the day the flow was created to the day it was archived. An active flow (no `archivedAt`) is served exactly as you asked for it. When the clamp trims your window, the response carries a `timeframe_clamped_to_entity_lifetime` warning and echoes the window it actually used.
## Totals versus one version
`filter[version]` is the parameter flows have and no other family does, and it changes what the whole report means.
**Without `filter[version]`, the report is the flow total across every version** — the `live` version and the `live-test` cohort added together. That is the honest business number: the A/B cohort is real traffic to real contacts, with real deliveries, real clicks and real revenue, so leaving it out would under-report what the flow actually did.
**With `filter[version]=`, every statistic is scoped to that single version.** Not just the delivery figures: clicks, conversions and revenue are scoped too, because attribution keeps the version alongside the flow on the events it credits. That is what makes the parameter useful — it is how you judge a test version on its own merits before deciding whether to promote it.
#### url
```text
GET /project/{project}/analytics/flow/{flow}/series
?statistics=delivered,click_rate,conversions,conversion_value,roas
&timeframe=last_30_days
&filter[version]=66b1f2a4e5a6b7c8d9e0f1b3
```
### Comparing two versions
There is no `group_by=version` and no compare endpoint for flows. You compare by issuing the **same request twice**, changing only the version id:
#### 1. List the flow's versions
Call `GET /project/{project}/flow/{id}/versions` and pick the `live` version and the `live-test` version.
#### 2. Report on each one
Issue two identical series requests — same statistics, same timeframe, same interval — differing only in `filter[version]`.
#### 3. Compare like for like
Read the rates rather than the raw counts. The A/B cohort runs on a small share of the audience, so its volumes are smaller by design; `click_rate`, `conversion_rate` and `roas` are what put the two versions on the same footing.
#### 4. Optionally, take the total
The same request with no `filter[version]` gives you the flow as a whole, both cohorts included — the number to report to the business.
> **Warning**: A malformed or empty `filter[version]` is **rejected**, never quietly ignored. Serving the all-versions total as though it were scoped to one version would be a silently wrong A/B result, so the request fails instead.
## Next
- [Building queries](/platform-api/product-api/analytics/building-queries) - Timeframes, intervals, breakdowns and filters, shared by every report.
- [Statistics catalog](/platform-api/product-api/analytics/statistics) - Every statistic a flow report can return.
- [Reading results](/platform-api/product-api/analytics/reading-results) - The series shape, freshness, warnings and errors.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/rate-limits
# Product API rate limits
The Product API is rate-limited per organization and per endpoint class. Ceilings depend on your subscription plan and can be raised by upgrading.
**Language:** en
**Audience:** developer
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/rate-limits/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/rate-limits.md (Markdown)
Every Product API endpoint enforces a request-per-minute ceiling. Limits are scoped by organization and by endpoint class — hot endpoints (scroll, search, aggregations) have their own budgets so a heavy reporting job cannot starve an interactive dashboard.
## Ceilings follow your plan
Base limits are tied to the subscription plan your organization is on. Upgrading the plan raises every ceiling across the board, typically by a generous multiplier per tier:
| Plan | Profile |
| ------ | ----------------------------------------------------------------------------------- |
| **S** | Starter workloads: a few interactive users, light sync. |
| **M** | Growing integrations: mixed read/write, several daily sync jobs. |
| **L** | Production scale: continuous sync, real-time dashboards, larger audiences. |
| **XL** | High-volume platforms: multi-region reporting, heavy aggregations, bulk operations. |
If you are hitting `429`s regularly and your workload genuinely needs the headroom, the path forward is to **upgrade the plan** — see the **Billing** section of the [dashboard](https://dashboard.instasent.com) or talk to your account manager. For one-off spikes (backfills, seasonal traffic, audits) open a ticket with the expected peak rate and we will raise the ceiling on your organization for the duration.
## Reading the headers
Every response includes three headers with the current window state. Log them in production — they are the cheapest way to spot a client that is about to hit the wall.
```
X-RateLimit-Limit 600
X-RateLimit-Remaining 587
X-RateLimit-Reset 1893452400
```
| Header | Meaning |
| ----------------------- | ------------------------------------------------------------------------------ |
| `X-RateLimit-Limit` | Total requests allowed in the current window, for the plan and endpoint class. |
| `X-RateLimit-Remaining` | Requests left before the window tightens. |
| `X-RateLimit-Reset` | Unix timestamp when the counter resets. |
## Designing for the ceiling
A few patterns keep a Product API integration comfortably below the line:
- **Scroll, don't paginate-and-forget.** The `/audience/scroll` and `/event/scroll` endpoints are built for large traverses — they hold a cursor, cost one hit per page and are generous on page size.
- **Cache project-level specs.** Attribute and event specs barely change; call `/specs/*` once per deploy, not per request.
- **Batch writes.** Ingest endpoints accept up to 100 items per call. A batched write counts as one hit.
- **Separate credentials per workload.** An interactive dashboard and a nightly backfill should not share a token — they share the same ceiling, which hides problems.
## When you hit the limit
Requests that exceed the window return **`429 Too Many Requests`**. The body is empty; the `X-RateLimit-Reset` header tells you when to retry.
> **Tip**: Back off exponentially rather than retrying in a tight loop. A client that hammers a 429 response keeps the window full and never recovers — waiting until `X-RateLimit-Reset` resolves the situation cleanly.
## What's next
- **[Errors](/platform-api/product-api/errors)** — every status code you might receive, including `429`.
- **[API Reference](/platform-api/product-api/reference)** — per-endpoint documentation.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.
---
URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/errors
# Product API errors
The Product API uses standard HTTP status codes. This page lists the ones you will encounter, what they mean and whether a retry is safe.
**Language:** en
**Audience:** developer
**Docs index (every page):** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt
**This zone's index:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/llms-full.txt
**This page:** https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/errors/ (HTML) · https://staging-instasent-docs-nextjs.oscar-284.workers.dev/platform-api/product-api/errors.md (Markdown)
Every response carries a meaningful HTTP status code. A `2xx` means the request succeeded; anything else signals a problem your client should handle explicitly. Error bodies, when present, are JSON with a `message` field describing the failure.
## Status codes
### `200 OK`
The request succeeded and the body carries the resource.
### `201 Created` / `202 Accepted`
Write endpoints return `201` for direct creates and `202` for batched writes that are accepted for asynchronous processing. Batched writes return a per-item outcome — inspect it before assuming the whole batch landed.
### `204 No Content`
The request succeeded and there is no body to return. Used for deletes and idempotent no-ops.
### `400 Bad Request`
The request body or query string is malformed or has the wrong shape. Not retryable — fix the payload first.
```json
{ "message": "Invalid query filter" }
```
### `401 Unauthorized`
The token is missing, revoked or malformed. Not retryable with the same token — check the [Authentication](/platform-api/product-api/authentication) guide.
### `403 Forbidden`
The token is valid but lacks the scope required for this endpoint, or the privacy level is insufficient to return the requested fields. Not retryable as-is — mint a token with the right scope, or upgrade the data privilege level. See [scopes in the Guide](/platform-api/product-api/guide#tokens-and-scopes).
### `404 Not Found`
The `project`, resource id, user id, phone, email or audience id in the URL does not exist or is not visible to the current token.
### `422 Unprocessable Entity`
Validation failed. For batched writes, every item in the batch failed — the body lists per-item errors. Not retryable as-is — fix the items and resubmit.
### `429 Too Many Requests`
You hit the per-endpoint rate-limit window. Retry after `X-RateLimit-Reset`. See [Rate limits](/platform-api/product-api/rate-limits).
### `500 Internal Server Error`
Something broke on our side. Retry with exponential backoff; if the failure persists, contact support with the request id.
## Retry policy
> **Tip**: Retry `429` and `5xx` with exponential backoff, starting at 1 s and capping at a minute or so. Everything in the `4xx` range other than `429` means the request itself is wrong — retrying will not help.
## What's next
- **[Rate limits](/platform-api/product-api/rate-limits)** — the `X-RateLimit-*` headers and plan ceilings.
- **[API Reference](/platform-api/product-api/reference)** — per-endpoint responses and schemas.
---
This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://staging-instasent-docs-nextjs.oscar-284.workers.dev/llms.txt — start there for full context.