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.
The Chat tab is where a team member reads and replies to fans, prices and sends PPVs, and
triages a queue. Everything under /api/admin/inbox and /api/admin/chatters builds on the
ideas below — read this first if a how-to page assumes one of them.
The inbox is sorted by who's waiting, not by who's newest
GET /inbox/conversations does not return "the N most recent threads." Each of its six tabs —
Waiting, Unanswered, Whales, Mine, All, Snoozed — runs its own SQL
WHERE and its own order (_tab_clause), because a shared "load the latest window, filter it
in the browser" approach was measured on a live 1,932-thread account and found most waiting fans
sitting past position 1,862 by recency — a window of the newest 200 threads surfaced 10 of 109
fans with something unread. Waiting and Unanswered both sort longest-wait-first, not
newest-first, for the same reason.
A thread counts as waiting when it's open, has unread inbound, and the fan's last message is newer than your last reply. Unanswered is the deliberate complement: open, nothing unread, but the fan still spoke last — someone read it and hasn't replied yet, on purpose or not. Every open thread you owe a reply to lands in exactly one of the two tabs, never both.
The tab counts are honest
The little numbers on each tab (counts.waiting, counts.whales, …) are computed over every
conversation this owner has, using the identical predicate each tab's list uses — never over the
page of rows a request happened to return. Before this was fixed, a 2,616-thread inbox showed
"10 waiting" while "All" capped out at "200": both numbers were counting the wrong set.
No message body is stored in the database
The Conversation row backing each thread holds only list/unread/SLA state — fan id, timestamps,
unread count, status, a short last-message preview capped at 200 characters. It does not hold
the conversation itself. Opening a thread (GET /inbox/conversations/{fan_id}/messages) re-reads
the actual messages live from the connected account every time, normalizes them, and serves them —
a read, not a write, so it carries no ban risk.
"We store nothing" is true of the database only
The engine that captures OnlyFans threads keeps its own on-disk cache — one JSON file per fan, rewritten whole on every capture, with no retention policy today. That cache is a separate pile with its own lifetime, outside the app's reach, and it isn't a place to recover history from either: anything the vendor stops returning (an unsent message) disappears from the next rewrite too.
Snooze, reopen, close: one state machine, no background job
Triage lives entirely on Conversation.status and Conversation.snooze_until, changed by a
single PATCH /inbox/conversations/{fan_id}/state with one of five actions: snooze, reopen,
close, mark_unread, mark_read. A snoozed thread is hidden from the default views while
snooze_until is still in the future; once it passes, the thread reads as open again the very
next time anyone asks — effective_status() computes this on read, so nothing has to wake up and
flip a column. The Snoozed tab is the mirror: it shows only threads still asleep, because a
thread whose time has come has already rejoined the open queue.
Every outbound message passes the same two gates
A 1:1 reply and a broadcast send both go through the content firewall, then the gated engine — in that order, and neither can be skipped from a different code path.
- Firewall (
firewall.lint) screens the text and media refs. Ablockedverdict stops the message cold: nothing is queued, no audit row is written, no engine call happens. The specific allow/deny categories are treated as part of the safety layer and aren't exposed in API responses. - The engine (
get_executor) actually attempts delivery. Whether it can deliver live at all depends on a global readiness flag — see Shadow sends and the kill switch for the full state machine and what each status means.
Sale attribution is a priority ladder, and "no one" is a valid answer
Every sale is resolved to the operator who most likely earned it, in strict priority order:
manual (an owner override, never auto-overwritten) → ground_truth (the most recent
outbound sent through our own service, tagged with a real operator) → last_outbound (the
most recent outbound on the platform, credited to whoever's shift covered it) →
shift (whoever's shift covers the sale time) → ownership (the operator the fan is
assigned to) → none. operator_id = None — "off shift" — is a legitimate terminal outcome:
revenue is never silently folded into someone's total or dropped from the leaderboard to make the
attribution look complete.
AI drafts, it never sends
POST /inbox/fan/{fan_id}/suggest-reply returns up to three short reply variants (fewer if the
owner's "Engine" setting asks for fewer) for a human to read, pick, and paste into the composer —
the endpoint has no path to /inbox/send and never queues anything itself. Three things have to
be true before it does anything: the owner has switched AI voice on (off by default), a
non-bypassable daily cost reservation has room, and the team member holds the inbox/ai_suggest
capability. Every returned variant is also passed through a word-boundary ToS scrub (leet-speak
decoded first) before it reaches the UI — a variant that fails is dropped, not edited.
The fan-economics card is a read-only rollup of our own rows
The "Fan economics" panel next to an open thread — lifetime and 30-day spend, the PPV
sent/unlocked funnel, a money breakdown by kind, the last purchase — is computed purely from
of_transactions and message_stats, tables this app already owns. It never calls the connected
platform, and it degrades to an honest empty state (known: false) rather than 500ing or
fabricating a zero rollup for a fan with no history yet.
The composer treats three PPV mistakes as money bugs, not typos
Sending a priced message has three failure modes that are invisible on screen if the composer gets
them wrong: dollars not converted to cents (the fan is charged 100× too little), the paywall
locked flag dropped when price > 0 (a paid file ships free), and a preview id that isn't a
subset of the attached media (OnlyFans rejects the send outright). See
PPV composer money bugs for
how the client guards against each.
What's next
Fan profile & signals
How Club scores a fan's RFM/lifecycle, resolves who sent them, and layers a second self-reported "discovery" signal on top of it — the one place every fan screen gets these labels from.
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.