/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. Both the count and the send scope tousers.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 <. 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.
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 fromcampaign_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.testedrather than anything that reads like a send.
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 carriesAND 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 — thosescheduled with a time in the past, plus everything
already running.
- A
scheduledcampaign is moved torunning, which stampsstarted_atonce. - 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
suppressedthe moment they are queued, with reasonsuppressedorno_telegram. A customer with no Telegram identity used to be queued for the bot, which never read them, so the row stayedqueuedand 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 markedcompleted.
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-wideAPP_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 withsegmentCode · 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.
Related
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.