# Instasent - product-api (full documentation) > Autocontained dump of every page under /developers/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 developer index at https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/llms.txt, which links every API (and, for product or dashboard questions, the whole-site index). Source: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/ Developer docs index (all APIs): https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/guide/ (HTML) · https://docs.instasent.com/developers/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](/developers/transactional-api/overview). If you only need to push customer data into a project, the [Ingest API](/developers/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](/developers/product-api/audience/overview). - **[Events](/developers/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](/developers/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, and [create campaign drafts](/developers/product-api/campaigns/overview) for a human to review and send. - **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](/developers/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](/developers/product-api/audience/contacts-and-attributes) in the [Audience](/developers/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](/developers/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 and the warnings that need attention, 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`), warnings surfacing senders or registers that need review, 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. Use these to drive an onboarding checklist or a "your project isn't ready yet" banner, or to gate sending in your own tooling. See the [API Reference](/developers/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](/developers/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](/developers/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](/developers/product-api/campaigns/reading-campaigns), including the project digest. - `PROJECT_CAMPAIGN_WRITE` — create campaign drafts. Required by [campaign creation](/developers/product-api/campaigns/creating-a-draft), and by [estimating and deleting](/developers/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 and their versions. ### 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`, `PROJECT_AUTOMATION_READ`, `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](/developers/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](/developers/product-api/quickstart) - Find a contact, read their events, send a direct SMS. - [Authentication](/developers/product-api/authentication) - Token types, scopes and rotation. - [Audience](/developers/product-api/audience/overview) - The whole audience surface: contacts, events, filters, probes, counting and segments. - [Campaigns](/developers/product-api/campaigns/overview) - Draft campaigns, read them back, price them and delete them. - [Full API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/quickstart/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/transactional-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, and what still needs my attention?" — handy for onboarding screens or a pre-flight check before you start sending. `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, add funds for prepaid projects). Each step carries a stable `key`, plus `completed`, `isBlocker` and `disabled` flags. ```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": false, "disabled": false }, { "key": "channels", "completed": true, "isBlocker": true, "disabled": false }, { "key": "legal_profile", "completed": false, "isBlocker": true, "disabled": false } ], "warnings": [] } } ``` `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 (such as `add_sender` for SMS or `add_agent` for RCS) it surfaces `warnings` — things you cannot fix by clicking a button, like senders sitting in regulatory review. Each warning has a `key`, `severity`, `count` and `rows[]`, where every row points at the affected register (`senderId`, `senderName`, `country`, `effectiveStatus`). The channel report also carries a `reach` object — the positive counterpart of `warnings`, answering "which countries can I send to right now on this channel?". It lists `reachableCountries` (the union of ISO 3166-1 alpha-2 codes across the channel's active senders, deduplicated and sorted) and a per-sender `senders` breakdown keyed by sender alias (the `from` for SMS, the agent name for RCS; senders that reach no country are omitted). `reach` uses the accepted carrier status — the same set as each sender's `acceptedCountries` — not the regulation-aware `effectiveStatus`, so it answers "can I send to country X?" in a single call. On the project (home) scope `reach` is `null`. ```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 } ], "warnings": [], "reach": { "reachableCountries": ["ES", "FR", "PT"], "senders": { "ACME": ["ES", "PT"], "INFO": ["FR"] } } } } ``` > **Tip**: The report is recomputed on every call — it is a live view of the project, never a cached "done" flag. Poll it to drive setup checklists, or read it once before a send to confirm the channel is `operational`. ## 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": "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](/developers/product-api/audience/query-filter#counting-who-can-receive-filtercompliance). ## What to read next - [Guide](/developers/product-api/guide) - Mental model, entities and how the unified audience is built. - [Audience query filter](/developers/product-api/audience/query-filter) - Search and segment the unified audience. - [Authentication](/developers/product-api/authentication) - Token types, scopes and rotation. - [Full API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/authentication/ (HTML) · https://docs.instasent.com/developers/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)](/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/rate-limits)** — per-plan ceilings and the `X-RateLimit-*` headers. - **[Errors](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/authentication, /platform/en/developers-and-apps/connect-your-ai **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/connected-apps-and-oauth/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/product-api/reference). A **connected app** is the second way to authenticate the Product API (the other is [API tokens](/developers/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](/developers/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](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/audience/contacts-and-attributes, /developers/product-api/audience/query-filter, /developers/product-api/audience/segments **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/overview/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/audience/segments). ## What lives in this section - [Contacts and attributes](/developers/product-api/audience/contacts-and-attributes) - How records from several sources become one contact, and what system and custom attributes it carries. - [Events](/developers/product-api/audience/events) - The event model: types, parameters, the project's catalogue, and what actually arrives. - [Query filter](/developers/product-api/audience/query-filter) - The grammar that selects contacts: attribute, event and segment conditions. - [Event query filter](/developers/product-api/audience/event-query-filter) - The grammar that selects events directly, with its own aggregation surface. - [Probes](/developers/product-api/audience/probes) - Measure what the audience actually contains before you filter it. Probe first, then filter. - [Counting and retrieval](/developers/product-api/audience/counting-and-retrieval) - Count, search, scroll: which endpoint answers which question, and what each returns. - [Segments](/developers/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.](/developers/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`](/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/audience/contacts-and-attributes)**: the merge model and the attribute catalogue, which is where most integration surprises come from. - **[Query filter](/developers/product-api/audience/query-filter)**: the grammar, once you know what you are filtering on. - **[Full API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/audience/probes, /developers/product-api/audience/query-filter **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/contacts-and-attributes/ (HTML) · https://docs.instasent.com/developers/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`](/developers/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.](/developers/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](/developers/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`](/developers/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](/developers/product-api/audience/query-filter#dynamic-attributes). ## What's next - **[Events](/developers/product-api/audience/events)**: the other half of what a contact carries. - **[Probes](/developers/product-api/audience/probes)**: which of these attributes actually hold data, and what values they hold. - **[Query filter](/developers/product-api/audience/query-filter)**: how to write a condition on an attribute once you know it is usable. - **[Counting and retrieval](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/audience/probes, /developers/product-api/audience/event-query-filter **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/events/ (HTML) · https://docs.instasent.com/developers/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.](/developers/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.](/developers/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](/developers/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.](/developers/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](/developers/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.](/developers/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](/developers/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](/developers/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](/developers/product-api/audience/event-query-filter) when the rows you want back are events, not people. ## What's next - **[Probes](/developers/product-api/audience/probes)**: which types arrive and what their parameters really hold. - **[Event query filter](/developers/product-api/audience/event-query-filter)**: the grammar for searching and aggregating events directly. - **[Query filter](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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, 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, 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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/query-filter/ (HTML) · https://docs.instasent.com/developers/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](/developers/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`](/developers/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": "opt-out" } } ``` Posted to `/project/{project}/audience/count`, that body returns how many contacts an SMS campaign under the `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 | | `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 `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. 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`, `opt-out`, `opt-in`. - **Several channels are AND-ed.** `{"sms": "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. 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](/developers/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](/developers/product-api/audience/segments)** page. [`POST /project/{project}/segment` - Save an audience filter as a named segment the project keeps.](/developers/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](/developers/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](/developers/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](/developers/product-api/audience/counting-and-retrieval)**: which endpoint to post this filter to, and what each of them returns. - **[Segments](/developers/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](/developers/product-api/audience/event-query-filter)** (EQF): the separate grammar for searching events directly. - **[Query Filter](/developers/further-reading/query-filter)** (QF): the generic filter for the Product API's list endpoints, unrelated to this one. - **[API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/event-query-filter/ (HTML) · https://docs.instasent.com/developers/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](/developers/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`](/developers/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](/developers/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](/developers/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](/developers/product-api/audience/query-filter)** (AQF) — the contact-side filter, with segment and group\_event conditions. - **[API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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 **Related pages:** /developers/product-api/audience/query-filter, /developers/product-api/audience/event-query-filter **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/probes/ (HTML) · https://docs.instasent.com/developers/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`](/developers/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.](/developers/product-api/reference) [`POST /project/{project}/event/volumes` - Event volumes: which event types this project actually receives, and what values their parameters carry.](/developers/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](/developers/product-api/rate-limits) class whose ceiling follows your plan. They are the companion of the specs endpoints: the [attribute catalogue](/developers/product-api/audience/contacts-and-attributes#the-attribute-catalogue) and the [event catalogue](/developers/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](/developers/product-api/audience/query-filter) against that attribute and that value. #### 4. Count Post it to [`/audience/count`](/developers/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](/developers/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](/developers/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 }, { "uid": "ecommerce_checkout_abandon", "label": "Checkout abandoned", "count": 0, "received": false, "lastSeenAt": null, "effectiveWindowDays": 15, "windowLimitedByRetention": false } ], "cached": true, "computedAgo": "2 minutes", "summary": "1 of 2 declared event types received anything in the last 15 days. A zero means nothing arrived in this window, not that the type does not exist." } } ``` The `window` block is the one to read: it reports the window that was **actually queried**, anchored to the instant of the call rather than to a calendar boundary. The `sampling` block beside it says what it covers: the **type counts only**, and those genuinely are never sampled, because the aggregation runs over about thirty distinct values and costs the same whatever the document count. So `applied: false` is honest. It ships anyway, with `covers` and `reason`, so a consumer never has to infer the method from a missing key, and its `parameterValues` key points at the block that answers the other half of the question. That other half is [The response carries two sampling blocks](#the-response-carries-two-sampling-blocks). ### One level deeper: what the parameters carry Pass `event` and the response gains an `event` block for that type, listing the real values of the parameters worth listing: product id and name, category, tags and vendor, the campaign id and name behind an attribution, its source and medium, statuses, methods. That is what turns "this project receives orders" into "this project sells these categories", which is the difference between guessing a filter value and reading one. > **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" } } } } ``` 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](/developers/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`](/developers/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](/developers/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](/developers/product-api/audience/contacts-and-attributes) and [Events](/developers/product-api/audience/events). Probes measure how much of that schema has data behind it. ## What's next - **[Audience query filter](/developers/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](/developers/product-api/audience/event-query-filter)** (EQF): the event-side grammar, and the `root` `/event/volumes` accepts. - **[Counting and retrieval](/developers/product-api/audience/counting-and-retrieval)**: where the number you may actually quote comes from. - **[Segments](/developers/product-api/audience/segments)**: keeping the filter the probe helped you write. - **[Full API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/audience/query-filter, /developers/product-api/audience/segments **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/counting-and-retrieval/ (HTML) · https://docs.instasent.com/developers/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](/developers/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.](/developers/product-api/reference) ```bash curl -X POST "$BASE/audience/count" \ -H "Authorization: Bearer $INSTASENT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "filterCompliance": { "sms": "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](/developers/product-api/audience/query-filter#counting-who-can-receive-filtercompliance). - **It is the endpoint that settles any number a probe suggested.** [Probes](/developers/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.](/developers/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.](/developers/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.](/developers/product-api/reference) [`GET /project/{project}/audience/user/{userId}` - By the `_user_id` your systems use.](/developers/product-api/reference) [`GET /project/{project}/audience/search/phone/{userPhone}` - By phone number. URL-encode the leading plus as `%2B`.](/developers/product-api/reference) [`GET /project/{project}/audience/search/email/{userEmail}` - By email address.](/developers/product-api/reference) The id these return is the one every other per-contact call takes, including [the contact's events](/developers/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`](/developers/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](/developers/product-api/audience/query-filter)**: the filter these endpoints take, including cursor mechanics and aggregation shapes. - **[Segments](/developers/product-api/audience/segments)**: scrolling a saved segment, and reading its cached size. - **[Probes](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/audience/query-filter, /developers/product-api/audience/counting-and-retrieval **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/audience/segments/ (HTML) · https://docs.instasent.com/developers/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.](/developers/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](/developers/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](/developers/product-api/guide#tokens-and-scopes). > **Tip**: Compose the filter against `/audience/count` before you save it, and [probe](/developers/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.](/developers/product-api/reference) [`GET /project/{project}/segment/{uid}` - One segment, including its stored filter.](/developers/product-api/reference) [`GET /project/{project}/segment/dynamic` - Only the dynamic segments that take no parameter.](/developers/product-api/reference) The list endpoint accepts the [generic query filter](/developers/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`](/developers/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.](/developers/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`](/developers/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](/developers/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.](/developers/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](/developers/product-api/audience/query-filter)**: the grammar a segment stores. - **[Counting and retrieval](/developers/product-api/audience/counting-and-retrieval)**: getting a live number, and reading contacts out. - **[Campaign audience targeting](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/creating-a-draft, /developers/product-api/campaigns/reading-campaigns, /developers/product-api/campaigns/estimating-and-deleting **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/overview/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/campaigns/creating-a-draft). | `PROJECT_CAMPAIGN_WRITE` | medium | | `GET /campaign/{id}` | Reads one campaign back. See [Reading campaigns](/developers/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](/developers/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](/developers/product-api/authentication) and [Rate limits](/developers/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`](/developers/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](/developers/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](/developers/product-api/campaigns/reading-campaigns#what-the-list-leaves-out) on purpose. ## What's next - [Creating a draft](/developers/product-api/campaigns/creating-a-draft) - The `POST` contract: the request body, the response, warnings, limits and error codes. - [Audience targeting](/developers/product-api/campaigns/audience) - Segments, inline filters and how to count the reach before you create anything. - [Message content](/developers/product-api/campaigns/message) - Copy, template variables, senders, RCS buttons, SMS fallback and languages. - [Dates and scheduling](/developers/product-api/campaigns/scheduling) - The three date forms, timezones, and why setting a date schedules nothing. - [Reading campaigns](/developers/product-api/campaigns/reading-campaigns) - One campaign, the filtered list, and the project digest a dashboard is built on. - [Estimating and deleting](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/overview, /developers/product-api/campaigns/audience, /developers/product-api/campaigns/message **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/creating-a-draft/ (HTML) · https://docs.instasent.com/developers/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.](/developers/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](/developers/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](/developers/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](/developers/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": "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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/campaigns/message#choosing-a-sender). - `compliance` — `string`, default: `the project's own policy` Consent policy applied when the audience is resolved: `basic`, `opt-out` or `opt-in`. See [Consent policy](/developers/product-api/campaigns/audience#consent-policy). - `audience` — `object`, default: `everyone` Who the campaign targets, as segment UIDs and inline filters. See [Audience targeting](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/errors). ## What's next - [Audience targeting](/developers/product-api/campaigns/audience) - Segments, inline filters and how to count the reach before you create anything. - [Message content](/developers/product-api/campaigns/message) - Copy, template variables, senders, RCS buttons, SMS fallback and languages. - [Estimating and deleting](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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, opt-out, basic, count contacts, audience count, reach, default policy **Related pages:** /developers/product-api/campaigns/creating-a-draft, /developers/product-api/campaigns/overview, /developers/product-api/audience/query-filter **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/audience/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/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. | | `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). 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](/developers/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.](/developers/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": "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](/developers/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](/developers/product-api/audience/query-filter) - The full grammar: every condition type, operator and value shape. - [Message content](/developers/product-api/campaigns/message) - What the audience you just defined is going to receive. - [Creating a draft](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/creating-a-draft, /developers/product-api/campaigns/overview, /developers/product-api/campaigns/audience **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/message/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/campaigns/scheduling) - Where the campaign sits on the calendar — and why nothing is scheduled here. - [Audience targeting](/developers/product-api/campaigns/audience) - Who receives what you just wrote. - [Creating a draft](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/creating-a-draft, /developers/product-api/campaigns/estimating-and-deleting, /platform/en/campaigns/creating-a-campaign **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/scheduling/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/product-api/campaigns/creating-a-draft) - The endpoint contract, the full request body, limits and error codes. - [Audience targeting](/developers/product-api/campaigns/audience) - Segments, inline filters and counting the reach up front. - [Message content](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/overview, /developers/product-api/campaigns/estimating-and-deleting, /developers/further-reading/query-filter **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/reading-campaigns/ (HTML) · https://docs.instasent.com/developers/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.](/developers/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](/developers/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](/developers/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.](/developers/product-api/reference) The list takes the shared [Query Filter](/developers/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.](/developers/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](/developers/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](/developers/product-api/campaigns/estimating-and-deleting) - The two operations whose results you collect here, and the states each one accepts. - [Query Filter](/developers/further-reading/query-filter) - The filtering, sorting and pagination syntax the campaign list shares with every other list endpoint. - [Campaign analytics](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/campaigns/overview, /developers/product-api/campaigns/reading-campaigns, /developers/product-api/campaigns/creating-a-draft **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/campaigns/estimating-and-deleting/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/campaigns/reading-campaigns). ## Estimating a draft [`PATCH /v1/project/{project}/campaign/{id}/estimate` - Start the estimation of a campaign.](/developers/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](/developers/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](/developers/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](/developers/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](/developers/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.](/developers/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](/developers/product-api/campaigns/reading-campaigns) - How you collect the result of both operations: one campaign, the list, and the project digest. - [Campaigns](/developers/product-api/campaigns/overview) - The status vocabulary both operations are gated by, and the rest of the section. - [Dates and scheduling](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/analytics/statistics, /developers/product-api/analytics/building-queries **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/overview/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/analytics/building-queries#which-report-supports-what) for what every report supports. [`GET /project/{project}/analytics/overview` - Account overview — aggregate totals.](/developers/product-api/reference) [`GET /project/{project}/analytics/overview/series` - Account overview — time series.](/developers/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.](/developers/product-api/reference) [`GET /project/{project}/analytics/campaign/{campaign}/series` - Campaign report — time series.](/developers/product-api/reference) [`POST /project/{project}/analytics/campaign/compare` - Compare a set of campaigns over one timeframe.](/developers/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.](/developers/product-api/reference) [`GET /project/{project}/analytics/automation/{automation}/series` - Automation report — time series.](/developers/product-api/reference) [`POST /project/{project}/analytics/automation/compare` - Compare a set of automations over one timeframe.](/developers/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.](/developers/product-api/reference) Full detail, discovery endpoints and the version model: [Flow reports](/developers/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.](/developers/product-api/reference) [`GET /project/{project}/analytics/direct/series` - Transactional rollup — time series.](/developers/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](/developers/product-api/analytics/statistics). - **One request grammar.** Timeframe, timezone, currency, breakdowns and comparison are expressed the same way everywhere. See [Building queries](/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/analytics/reading-results), whose figures are exact and match billing. ## Where to go next - [Statistics catalog](/developers/product-api/analytics/statistics) - Every statistic you can request, what it means, and which families expose it. - [Building queries](/developers/product-api/analytics/building-queries) - Timeframes, timezone, currency, breakdowns and comparisons. - [Conversions & revenue](/developers/product-api/analytics/conversions) - How revenue statistics are computed and scoped. - [Reading results](/developers/product-api/analytics/reading-results) - The response shapes, freshness, warnings and errors. - [Flow reports](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/analytics/overview, /developers/product-api/analytics/conversions **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/statistics/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/analytics/reading-results#errors)). The [flow report](/developers/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](/developers/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](/developers/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](/developers/product-api/analytics/building-queries) - Combine these statistics with timeframes, breakdowns and comparisons. - [Conversions & revenue](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/analytics/statistics, /developers/product-api/analytics/reading-results **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/building-queries/ (HTML) · https://docs.instasent.com/developers/product-api/analytics/building-queries.md (Markdown) Every Analytics report shares the same request grammar. Beyond the required `statistics` list ([Statistics catalog](/developers/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](/developers/product-api/analytics/reading-results#grouped-values) and [Grouped series](/developers/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](/developers/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](/developers/product-api/analytics/flows#comparing-two-versions). ```text ?compare_to=previous_period ``` See [Reading results](/developers/product-api/analytics/reading-results#comparison) for the comparison shape, and the [campaign / automation compare reports](/developers/product-api/analytics/reading-results#compare-reports) for ranking several entities at once. ## Next - [Reading results](/developers/product-api/analytics/reading-results) - Response shapes, freshness, warnings and errors. - [Conversions & revenue](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/analytics/statistics, /developers/product-api/analytics/building-queries **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/conversions/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/product-api/analytics/statistics) for the delivery, engagement and cost statistics you can combine with these. ## Next - [Building queries](/developers/product-api/analytics/building-queries) - Timeframes, currency, breakdowns and comparisons. - [Reading results](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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:** /developers/product-api/analytics/building-queries, /developers/product-api/analytics/statistics **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/reading-results/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/analytics/building-queries#cross-cutting-two-dimensions). For which `group_by` each report accepts, see the [support matrix](/developers/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](/developers/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](/developers/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](/developers/product-api/analytics/statistics) - Every statistic and which family exposes it. - [Building queries](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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 built in the dashboard; the API is read-only. 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:** /developers/product-api/analytics/building-queries, /developers/product-api/analytics/statistics **Docs index (every page):** https://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/analytics/flows/ (HTML) · https://docs.instasent.com/developers/product-api/analytics/flows.md (Markdown) Flows are the branching journeys you build in the dashboard. The Product API does not build them — it exposes them **read-only**, so an integration can find a flow, 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. Everything on this page is governed by a single scope, `PROJECT_AUTOMATION_READ`. > **Note**: There is no API to create, edit, activate or promote a flow. Authoring and version promotion happen in the dashboard; the API is for reading and measuring. ## 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.](/developers/product-api/reference) [`GET /project/{project}/flow/{id}` - Retrieve a single flow.](/developers/product-api/reference) The listing accepts the standard [Query Filter](/developers/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.](/developers/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.](/developers/product-api/reference) It follows the same grammar as every other family ([Building queries](/developers/product-api/analytics/building-queries)) and returns the same series shape ([Reading results](/developers/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](/developers/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](/developers/product-api/analytics/building-queries) - Timeframes, intervals, breakdowns and filters, shared by every report. - [Statistics catalog](/developers/product-api/analytics/statistics) - Every statistic a flow report can return. - [Reading results](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/rate-limits/ (HTML) · https://docs.instasent.com/developers/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](/developers/product-api/errors)** — every status code you might receive, including `429`. - **[API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context. --- URL: https://staging-instasent-docs-nextjs.oscar-284.workers.dev/developers/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://docs.instasent.com/llms.txt **This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt **This page:** https://docs.instasent.com/developers/product-api/errors/ (HTML) · https://docs.instasent.com/developers/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](/developers/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](/developers/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](/developers/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](/developers/product-api/rate-limits)** — the `X-RateLimit-*` headers and plan ceilings. - **[API Reference](/developers/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://docs.instasent.com/llms.txt — start there for full context.