TEASEDocs
ProductsClubConcepts

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.

  1. Firewall (firewall.lint) screens the text and media refs. A blocked verdict 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.
  2. 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

On this page