> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Audience segments and campaigns

> Define a reviewable audience, build and schedule a send, and read what a campaign actually reached

A campaign is one message template sent to one audience segment on a schedule,
with a row per recipient recording what happened to them. It lives at
`/admin/marketing`, which needs `marketing.read` to open.

The screen is built around the moment before a send. A campaign is created in
draft with its estimated audience already visible, scheduling is a second
action, and the segment's definition is rendered in words beside the count —
because the mistake this page exists to prevent is sending the right message to
the wrong people, and nobody catches that by reading a segment code.

> [!NOTE]
>
> Several parts of this area are API-only today. Segments, templates, and
> suppression entries can be read in `/admin/marketing` but are created through
> `/v1/panel` requests, not through a form. Each section below says which.

## A segment is filters, never stored SQL

An audience segment is a named set of filters, stored as JSON and compiled to
parameterised SQL at read time. It is deliberately not a saved query fragment,
for two reasons that each outweigh the flexibility a stored fragment would buy.

An operator has to be able to read what a segment selects before sending
anything to it, and an auditor has to be able to check afterwards what it
selected. Neither is possible if the definition is a query somebody wrote once
and nobody can now interpret.

And a stored fragment is an injection surface with an audit trail attached.
Every value here reaches SQL as a parameter, and the vocabulary is closed, so a
segment cannot express anything the vocabulary does not allow — including
reading a table it has no business reading.

### The vocabulary

| Filter | Accepted value | Selects | Reads as |
| - | - | - | - |
| `status` | `active`, `suspended`, `deleted` | Account status | "account status is active" |
| `locale` | `en`, `ru` | Interface language | "interface language is ru" |
| `subscription` | `active` | Holds a live entitlement ending in the future | "holds a live subscription" |
| `subscription` | `expired` | Had an entitlement, has none live | "had a subscription and no longer does" |
| `subscription` | `none` | Has never had one | "has never had a subscription" |
| `expiringWithinDays` | Integer above zero | Live entitlement ending within N days | "subscription ends within 7 days" |
| `inactiveForDays` | Integer above zero | No `paid` or `fulfilled` order in N days | "has not paid for anything in 60 days" |
| `minimumSpendMinor` | Integer, zero or above | Settled spend at or above the amount | "has spent at least 500000 (minor units)" |
| `loyaltyTier` | A tier code | Currently standing in that tier | "stands in the gold loyalty tier" |
| `planCode` | A plan code | Holds an entitlement for that plan | "holds the pro plan" |

A key outside this list is refused on write with `unknown audience filter`, and
a value the filter cannot use with `invalid audience filter value`; both surface
as 422. Refusing on write rather than at send time is deliberate — silently
dropping a filter would send to a wider audience than the operator asked for,
and discovering that when the send begins is discovering it too late.

Numbers arriving as JSON floats must be whole. `expiringWithinDays: 7.5` is a
refusal, not a rounding.

### It explains itself

Every segment carries a generated explanation of what it selects. It is
generated from the filters rather than typed by the operator, so it cannot
describe something the query does not do, and the panel renders it as a bulleted
list beside the live size.

Filters are compiled in sorted key order regardless of how the JSON object was
written, so the same definition always produces the same SQL and the same
explanation. A segment whose rendering changes between reviews is a segment
nobody can approve.

> [!WARNING]
>
> **An empty filter set is every customer.** It compiles to `true` and explains
> itself as "every customer". That is legitimate — an outage notice means
> everybody — but it is stated in words rather than rendering as a blank
> definition, so nobody mails the whole base by forgetting to add a filter.

Both the count and the send scope to `users.status = 'active'` before any filter
is applied, so a `status` filter can only ever narrow that further. A segment
built from `status: deleted` selects nobody.

### Saving one

There is no form. Send:

```bash theme={null}
curl -X PUT https://panel.example.com/v1/panel/marketing/segments \
  -H 'Content-Type: application/json' \
  --cookie "$PANEL_SESSION_COOKIE" -H "X-CSRF-Token: $CSRF" \
  -d '{
    "code": "lapsed",
    "nameEn": "Lapsed customers",
    "nameRu": "Ушедшие клиенты",
    "filters": {"subscription": "expired", "inactiveForDays": 60}
  }'
```

`code` matches `^[a-z][a-z0-9_-]{1,63}$` and names are 1–80 characters. The save
needs `marketing.write` and writes an audit record, `panel.segment.saved`,
carrying the whole `filters` object — because "who changed what a campaign meant
by *lapsed customers*" is exactly what an audit trail exists to answer.

## Templates declare their own variables

A message template is a bilingual subject and body pair with a declared variable
list and a class. A body or subject referencing a `{{ variable }}` that is not in
the declared list is refused on write. The alternative is a customer receiving
`Hello {{name}}` because nobody checked, and by then it has gone to everybody.

Both languages are required, for the same reason canned support replies require
both: the language of the customer is not the language of the operator.

Bodies are sent with Telegram's HTML parse mode, so they are held to the same
markup rule as [reworded notices](https://omniflow-vpn.mintlify.site/operations/message-wording.md): only the tags
Telegram accepts (`b`, `strong`, `i`, `em`, `u`, `s`, `strike`, `del`, `code`,
`pre`, and `a` with an `https` link), every tag closed, and a literal `<` written
as `&lt;`. Anything else is refused on write as 422. `Скидка <50%` used to be
accepted and then refused by Telegram for every recipient of the campaign.
Subjects are escaped before sending and may contain anything.

When Telegram refuses a message anyway, the refusal is recorded once as
`failed` and not retried — sending the same text again gets the same answer —
and it does not count against the customer's delivery state, because their chat
is fine.

Authoring is API-only: `PUT /v1/panel/marketing/templates` with
`{code, class, subjectEn, subjectRu, bodyEn, bodyRu, variables}`, needing
`marketing.write`. Bodies are 1–3500 characters, subjects may be empty and are
capped at 200, and `class` defaults to `marketing`.

> [!WARNING]
>
> **A declared variable renders as nothing.** Campaign delivery substitutes an
> empty value map and then strips any placeholder that survives, and no part of
> the campaign model stores per-recipient values. A body written as
> `Hello {{name}}` is sent as `Hello ` with nothing in place of the placeholder.
> Write templates without variables until values exist to fill them.
>
> **A `transactional`-class template cannot be delivered.** Delivery classifies
> the message by passing the template's class string to the notification
> classifier, which knows notification *kinds*; `marketing` happens to be one and
> `transactional` is not. A transactional template makes the notifier log
> "campaign classification failed" and leave every recipient `queued`, which also
> stops the campaign from ever completing. Use `marketing`, which is the default.

## Building and scheduling a send

**1. Open the campaigns card**

`/admin/marketing`. With `marketing.write` a dashed composer appears above
the campaign table.

**2. Name it, pick a segment, pick a template**

The segment dropdown shows each segment's English name with its live size.
Choosing one renders an alert previewing who this reaches, in the segment's
own generated words, before anything is created.

**3. Create draft**

`POST /v1/panel/marketing/campaigns` with `{name, segmentId, templateId}`.
The audience is counted now and stored as `estimatedAudience`. Nothing is
queued — a draft that had already queued its recipients would make the
review a formality. The estimate is counted with the same compiled fragment
the send will walk, so what an operator approves and what receives the
message cannot come from different definitions.

**4. Send yourself a preview**

**Preview EN** and **Preview RU** each queue one copy of the message for the
operator group, rendered exactly as a customer would receive it. Nothing else
receives it and nothing about the campaign changes. See
[Previewing a campaign](#previewing-a-campaign).

**5. Schedule it**

Press **Schedule**, which reveals a date and time picker — a calendar for
the day, then hour and minute from two lists. Days in the past are not
offered, and the button stays disabled until a moment is chosen. That posts
`{"state": "scheduled", "scheduledFor": "<ISO 8601>"}` to
`/v1/panel/marketing/campaigns/{campaignID}/state`. Scheduling without a date
is 422: "send immediately" and "somebody forgot the date" must not be the
same request.

**6. Watch it, and stop it if it is wrong**

The worker moves the campaign to `running` at the appointed time and expands
the audience. **Pause** or **Cancel** from the same control.

## Previewing a campaign

`POST /v1/panel/marketing/campaigns/{campaignID}/test` with `{"locale": "en"}`
or `{"locale": "ru"}` queues one copy of the campaign's message for the operator
group. It requires `marketing.write`, because reading a campaign should not be
able to put anything into a chat.

What arrives is the campaign's own message, rendered by the same code that
renders a real send: template variables become nothing, because that is what a
customer receives — a preview that filled in sample values would be a preview of
copy nobody will ever get. It goes to a forum topic of its own, **📣 Campaign
previews**, under a header naming the campaign, the language, and the fact that
nobody else received it.

Two things it deliberately does not touch:

* **The recipients.** The record lives in `campaign_test_sends`, a table of its
  own. Every counter beside the campaign — queued, sent, failed, suppressed — is
  derived from `campaign_recipients`, and the audience expansion skips anybody
  who already has a row there. A preview recorded as a recipient would both move
  the numbers an operator is about to judge the campaign by and remove a real
  customer from the send. Neither is recoverable once the campaign has run.
* **The campaign.** No state changes, no counter moves, and the audit entry is
  `panel.campaign.tested` rather than anything that reads like a send.

It is refused for a `completed` or `cancelled` campaign. A preview is for a
decision that is still open, and copy in the operator group for a campaign that
will never run again reads as though it might.

Delivery is the bot's, so the panel reports that the preview was **queued**. It
arrives on the notifier's next pass, within about ten seconds. A preview that
cannot be delivered — no operator group configured, the bot lacking permission
to create a topic — is recorded as `failed` with a category, and is not retried:
asking for another one is a click, and a retry loop against a chat that is
refusing messages fills the queue rather than the chat.

> [!NOTE]
>
> It needs an operator group. With none configured the bot's notifier does not
> run, and previews stay `queued`. See
> [Telegram](https://omniflow-vpn.mintlify.site/integrations/telegram.md) for binding one.

### The permitted transitions

The state machine is enforced in the statement rather than in the caller: the
update carries `AND status = ANY(allowed_from)`, so a refused transition matches
no row and the API answers **404**, not a validation error.

| Target state | Reachable from |
| - | - |
| `scheduled` | `draft`, `paused` |
| `running` | `draft`, `scheduled`, `paused` |
| `paused` | `running` |
| `cancelled` | `draft`, `scheduled`, `paused` |
| `completed` | `running` |

A completed campaign cannot go back to running, and a cancelled one cannot be
resurrected with a different audience. A running campaign cannot be cancelled
directly — pause it first.

> [!NOTE]
>
> The panel offers **Pause** on a scheduled campaign, but `paused` is only
> reachable from `running`, so that button answers 404. Cancel the campaign
> instead, or re-schedule it for a later time. Note also that any transition
> other than scheduling clears `scheduled_for`, so a campaign that is paused and
> resumed needs its date set again.

### What the worker does

The campaign runner in the worker process ticks every minute and takes up to 20
due campaigns — those `scheduled` with a time in the past, plus everything
already `running`.

* A `scheduled` campaign is moved to `running`, which stamps `started_at` once.
* The segment is recompiled on each pass. **A segment that no longer compiles
  pauses the campaign** rather than running it against an audience nobody can
  read.
* Up to 5000 recipient rows are inserted per pass. The recipient row's composite
  primary key is the deduplication, so a paused-and-resumed campaign continues
  rather than restarting, and a customer cannot be queued twice.
* A customer on the suppression list, or with no Telegram identity the bot could
  message, is resolved as `suppressed` the moment they are queued, with reason
  `suppressed` or `no_telegram`. A customer with no Telegram identity used to be
  queued for the bot, which never read them, so the row stayed `queued` and the
  campaign never completed.
* Counters are recomputed from the recipient rows after each pass rather than
  incremented, so they cannot drift.
* When a pass queues nobody new and nothing is left `queued`, the campaign is
  marked `completed`.

> [!NOTE]
>
> Expansion used to fail for any segment carrying a bound value, which is nearly
> all of them. The statement passed the campaign id as `$1` and appended the
> segment's own arguments after it, while the compiled filter numbers its
> placeholders from `$1` as well; the two collided, PostgreSQL rejected the
> bind, and the pass logged "campaign expansion failed" and queued nobody —
> indistinguishable from the panel from a segment that matches nobody. The
> segment's arguments now come first and the campaign id last. An integration
> test expands a `locale` segment and asserts the one customer it names is
> queued.

## Delivery, and who is skipped

The worker owns the audience. The bot's notifier owns delivery, because consent,
quiet hours, frequency caps, and Telegram delivery health already live there —
duplicating them would mean two places that can disagree about whether a
customer may be messaged. It drains queued recipients of running campaigns 200 at
a time, reading the next batch as soon as the last is sent and looking again
every 30 seconds once nothing is queued, and records an outcome per recipient.
The pace is the bot-wide `APP_TELEGRAM_DELIVERY_RATE`, 25 messages a second by
default; it used to be 200 per fifteen-minute pass, about 800 an hour. A
Telegram flood wait leaves the rest of the batch queued for the next one rather
than marking it failed.

The 200 are shared between running campaigns: each contributes its oldest
recipients, and the pass takes the first of every campaign, then the second,
and so on. A campaign with a large backlog cannot hold a small one behind it,
which a single queue ordered by queue time did. Every queued recipient is read,
reachable or not, so none can stay `queued` for ever: a customer with no
Telegram identity, one whose account was suspended or deleted after expansion,
and one whose chat is known to refuse messages are each resolved with a reason,
and a template whose class the delivery policy does not recognise fails the
recipient with `classification_failed` instead of leaving it queued.

A recipient the policy refuses is recorded as suppressed with a reason, never
silently dropped:

| Status | Reason | Meaning |
| - | - | - |
| `sent` | — | Telegram accepted the message |
| `failed` | — | Telegram rejected it; `error_code` is `send_failed`, or `classification_failed` for a template class the policy does not know |
| `suppressed` | `no_telegram` | No reachable Telegram identity; `error_code` is `account_inactive` when the account was suspended or deleted after expansion |
| `suppressed` | `suppressed` | On the suppression list when the audience was expanded |
| `suppressed` | `no_consent` | Marketing consent absent, or the news preference off |
| `suppressed` | `frequency_cap` | Already had their marketing allowance this window |
| `suppressed` | `quiet_hours` | Inside their quiet window |
| `suppressed` | `delivery_blocked` | Blocked the bot, never started it, or the account is deactivated |

A campaign **records** a quiet-hours refusal rather than holding the message
until morning. A marketing send is not urgent enough to arrive late in a batch
that no longer matches what the operator wrote it for.

The subject, when a template has one, is prepended to the body in bold. Rendered
bodies are never stored: the template plus the delivery record is the history,
and copying the body into a campaigns table would duplicate customer-facing
content into a second retention regime for no operational gain.

Every recipient is evaluated against the customer's marketing switch regardless
of the template's class, and the resolution statement only matches a row still
`queued`, so a retried delivery cannot overwrite a recorded outcome.

For the classification rules, quiet-hours arithmetic, and the frequency cap
settings themselves, see
[News and customer communication](https://omniflow-vpn.mintlify.site/commerce/referrals-communication.md).

## Reading the result

The campaign table shows, per campaign: the name with `segmentCode · templateCode`
beneath it, a status badge carrying the scheduled time, the estimated audience,
and a result triple — **sent / failed / suppressed**.

Suppressed is reported beside sent rather than folded into it, because "we did
not message these people on purpose" is a different fact from a failure. An
operator reviewing reach needs to see that four hundred people were on the list
and were not contacted, and why. A campaign that simply reported a smaller
audience would hide the decision, and a smaller audience with no explanation is
indistinguishable from a broken send.

The gap between `estimatedAudience` and `queued + sent + failed + suppressed` is
therefore the honest measure of how far expansion has got, not a discrepancy.

## The suppression list

`communication_suppressions` holds one row per customer with a reason from a
closed set — `customer_request`, `bounced`, `complaint`, `operator` — an optional
note, and who added it. Any other reason is 422.

It is deliberately not the marketing consent flag. Consent is a preference the
customer toggles; a suppression is a standing instruction that survives them
toggling it back on by accident.

`/admin/marketing` → **Suppressions** lists customer id, reason, note, and a
**Remove** button for anyone with `marketing.write`. Adding one is API-only:
`PUT /v1/panel/marketing/suppressions` with `{customerId, reason, note}`, upserted
by customer, so re-suppressing overwrites the reason and note. Both write audit
records, `panel.suppression.added` and `panel.suppression.removed`.

A customer who unsubscribes from `/account/preferences` writes their own row with
reason `customer_request`, and re-consenting later deletes **only** that reason. A
suppression recorded for a bounce or a complaint is somebody else's finding about
deliverability, and a customer ticking a box does not overturn it. The customer's
preferences screen reports an active suppression with its reason and date but
offers no control to lift one.

There is no expiry column, so a suppression stands until an operator removes it.

## Permissions

| Action | Permission |
| - | - |
| Read segments, templates, campaigns, and suppressions | `marketing.read` |
| Save a segment or template, create or move a campaign, add or remove a suppression | `marketing.write` |
| Change what the referral programme pays | `settings.write` |

Every write here is audited: `panel.segment.saved`, `panel.template.saved`,
`panel.campaign.created`, `panel.campaign.<state>`, `panel.suppression.added`,
and `panel.suppression.removed`.

## Related

> **[News and customer communication](https://omniflow-vpn.mintlify.site/commerce/referrals-communication.md)**
>
> The announcement inbox, notification classes, consent, quiet hours, and the
> frequency cap.

> **[Message wording](https://omniflow-vpn.mintlify.site/operations/message-wording.md)**
>
> The transactional notices, which are reworded elsewhere and for different reasons.

> **[Customer notifications](https://omniflow-vpn.mintlify.site/customer/notifications.md)**
>
> What a customer sees and controls on their side.

> **[Admin panel](https://omniflow-vpn.mintlify.site/operations/admin-panel.md)**
>
> Sessions, roles, and the rest of `/admin`.

> **[Jobs and queues](https://omniflow-vpn.mintlify.site/operations/jobs-and-queues.md)**
>
> The worker loops that expand campaigns and publish scheduled posts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.