Campaigns & promotions concepts
The mental model behind broadcasts, automation, smart links, the free pool, native OF promos, and referrals — and the traps between look-alike names.
This area covers everything that pushes a message or an offer at your fan base, or routes traffic toward one: mass-DM broadcasts, the separate automation-campaign system, the four fixed auto-messages, Route-Spine smart links, the free-link pool, native OnlyFans promos/bundles, and the referral program. Read this first — the how-to pages assume the ideas below.
Two campaign systems exist, and they are not merged
Broadcast is TEASE's own mass-DM tool: a Campaign row (the plan) plus one
CampaignSend row per fan — a throttle-queue the sender worker drains one fan at a time. The
sender only ever calls a 1:1 send, never a vendor "broadcast" call — that is the ban-safety
property of the whole design. A campaign's mode (segmented | tiered | drip |
online_trigger) decides how the audience is picked and sequenced, and its mechanism decides
how it's delivered: 1to1 (our paced per-fan send, personalization/tiers/drip all work, lands
in the fan's Priority inbox) or native (one OnlyFans native mass message to a list — no
per-fan features, lands in "All").
Separately, a Track B automation campaign lives on its own automation_campaigns table —
audience × payload (template / sequence / PPV / scheduled post) × schedule. It starts
mode='shadow' (renders and logs, sends nothing) and needs a passed soak run before mode can
be promoted to 'live' (POST /promote-live); status is a separate field tracking the
campaign's own lifecycle (draft/soaking/scheduled/...) and never takes the value 'live' itself.
Coexist, not compete
The automation model's own docstring calls unifying the two systems an open owner decision — they are not merged today, and nothing in the code picks one as "the real one". Building against the wrong table, or expecting a Broadcast campaign to show up in the automation overview (or vice versa), is the single most common integration mistake in this area.
Two safety rails sit under every automated send
A global kill-switch (GET/POST /api/admin/automation/kill-switch) is a cross-tenant,
file-backed emergency brake — flipping it halts automation sends for every owner at once. It is
separate from each owner's own opt-in kill switch; the file fails open (missing/corrupt state
reads as "not active"), so the per-owner opt-in stays the real gate day to day.
Every outbound text write — templates, sequences, PPV offers, scheduled posts — passes through a
deterministic, offline content firewall lint that stamps a firewall_state of
clean / flagged / blocked. A blocked verdict must never be sent — but the firewall does
not reject the write itself (a draft is allowed to hold blocked content); the hard reject happens
later, at enqueue. The firewall mechanism itself is shared across Club and is documented in the
shared glossary, not repeated here.
The four auto-messages are Track B campaigns you edit, not create
Welcome, subscription-expiring, subscription-expired, and fan-online are four
fixed-kind Track B campaigns. A database constraint enforces at most one live row per kind per
owner, so POST /api/admin/broadcasts/automessages is an upsert: it edits the one campaign of
that kind if it exists, never creates a second. Each kind reads a different threshold from the
same send_window_json knob block — within_days (expiring/expired), within_minutes +
cooldown_hours (online), resend_after_days (a reminder, on every kind except online, which
uses its own cooldown instead). Because these campaigns are Track B underneath, they inherit
Track B's shadow-first posture: a shadow log records who each auto-message would have
reached, with the real rendered text, while live sending stays off.
Route-Spine smart link: one link, a policy decides the offer
A Smartlink (Route-Spine) is one creator-facing /r/<slug> link per domain. Its policy_json
decides, per visitor, which arm — paid or ftl (free-trial) — they get: a weighted split,
sticky by subnet so a returning visitor keeps the same arm, with per-country overrides. By
default the trial arm only fires for a country that has its own permanent trial destination
(require_country_link: true) — everywhere else silently gets the paid arm, not a foreign
trial. The two old fixed addresses /r/paid and /r/ftl are retired; they still resolve (as
aliases into the domain's own Smartlink policy) but nothing in the product offers them anymore.
Not the same object as OnlyFans' own pooled smart link
A completely different, similarly-named feature — OfSmartLink, the registry of
onlyfansapi.com-issued pooled smart links, plus its Meta/TikTok CAPI pixel config — is served
by a different file (admin/smart_links.py, note the underscore) and keyed on a different
model. It is documented separately in the shared glossary. Confusing the two misattributes an
endpoint to the wrong feature.
The free pool: reusing a spare link instead of minting a new one
Pool membership is exactly of_campaigns.hidden == 1 — no separate table. Every OnlyFans link a
scan finds with no traffic and that the owner (or an auto-hide rule) has hidden becomes a spare
in the pool, split into two logical pools by arm (tracking → paid, trial → ftl). Dressing
a slot reuses one spare onto a (domain, country, arm) slot: it provisions TEASE's own
segment_links row pointing at the spare's already-existing OF offer, binds the domain as that
link's source, and the spare leaves the pool — no OnlyFans write, no new mint. It is idempotent
(re-dressing an already-live slot just returns the existing row) and reversible: reclaiming a
slot returns the link to the pool rather than retiring it. Every write here touches only
of_campaigns visibility/layout keys and TEASE's own segment_links / source_link rows —
never fan_link or of_transactions — which is what makes the whole mechanism money-safe.
Native OF promo / bundle: TEASE mirrors OnlyFans, it doesn't drive it live
A native promo is OnlyFans' own on-platform offer — either a free_trial (1–30 days) or a
first_month percentage discount (5–65%, step 5) — targeted at all_subscribers,
new_subscribers, or expired_subscribers. A bundle is a fixed 3/6/12-month subscription
slot with its own discount (0–50%, step 5). TEASE never writes either synchronously: every create
/ stop / delete queues an engine job and answers {"status": "pending", "nonce": ...}
immediately — the caller polls /api/admin/promo/ops/{nonce} for queued / done / failed /
stale. This is deliberate, not a performance shortcut: a synchronous wait doesn't survive a pod
restart or a dropped connection, and a lost wait invites a retry that would create a second
live promo on OnlyFans.
Reads of the promo/bundle lists are a mirror of what the engine last saw on the promo page.
The envelope's connected field is a plain boolean — true only if something was captured
recently (within 3 hours) — so telling apart the three real-world cases takes two fields, not
one: connected: false with captured_at: 0 means never captured; connected: false with a
real captured_at means the last capture is stale; connected: true with an empty items list
means captured recently and genuinely nothing is running.
Offer entry kind: what a fan took, kept beside where they came from
When a fan's OnlyFans subscription-feed row carries a sub_type of trial, promo, or
bundle, that fact is stored next to fan_source (where they came from) — never inferred,
and never substituted for it. "How they entered" and "where they came from" are different
questions with different answers; a promo has no address of its own, so it can never itself be a
traffic source.
Impact-before-delete: what breaks, shown before you break it
Before deleting a trial link, a tracking link, a promo, or a bundle, one read
(GET /api/admin/promo/impact) answers "what breaks" — chiefly which (domain, country)
routing places lose their only link. Its one hard rule: an unmeasurable counter comes back
null with a note explaining why, never a 0 — a 0 means "we looked and it was empty",
which is a different, more dangerous claim to make right before an irreversible delete.
Referral program: an end-user feature, not an admin one
The referral program (/api/me/referral/*) is unrelated to the workspace-owner admin panel — it
is a TEASE-account-level invite-code system for any signed-in user, authenticated by browser
session cookie rather than an al_live_* API key. A durable code is minted lazily on first read,
and every query is scoped strictly to the caller's own uid: one referrer can never read or
claim another's code or stats.
What's next
Broadcast creation/scheduling has its own how-to page, written alongside 1:1 chat — see Create and schedule a broadcast.
Start with creating a smart link, or jump to configuring the four auto-messages if lifecycle messaging is what you need.
Inbox & messaging concepts
The mental model behind the Chat tab — why the inbox sorts the way it does, what is and isn't stored, and the gates every outbound message passes through.
Money & Revenue
The rules every dollar in Club obeys — net cents, what counts as a purchase, money-kind groups, the fail-closed money mask, and the two revenue sources (OnlyFans/Fansly transactions and Tribute).