Skip to main content
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.
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

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.
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:
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: 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.
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.
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.
It needs an operator group. With none configured the bot’s notifier does not run, and previews stay queued. See Telegram 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. 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.
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.
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: 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.

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

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

News and customer communication

The announcement inbox, notification classes, consent, quiet hours, and the frequency cap.

Message wording

The transactional notices, which are reworded elsewhere and for different reasons.

Customer notifications

What a customer sees and controls on their side.

Admin panel

Sessions, roles, and the rest of /admin.

Jobs and queues

The worker loops that expand campaigns and publish scheduled posts.