Chat & broadcasts
Read your DM inbox waiting-first, price a fan, and run paced, ban-safe mass-messaging campaigns.
TEASE gives you two messaging surfaces over one connected account: a unified DM inbox
that surfaces the fans who are waiting on a reply first, and broadcasts — paced,
ban-safe mass-messaging campaigns with audience preview and per-campaign ROI. Both are
read and driven entirely through your al_live_* key.
Safety is the product, not a setting
Every outbound message — a 1-on-1 reply or a campaign send — passes through a content screen and a paced, gated delivery engine before anything could ever leave the building. Live delivery is off by default: today the send path screens and logs intent without contacting the platform. The endpoints below are stable; the two that actually deliver are marked Beta / gated and described honestly.
How the pieces fit
Inbox
The thread list, the live thread view, and per-fan pricing — read-only, waiting-first.
Send a DM
A single 1-on-1 message. Screened and gated; returns a verdict. Beta.
Broadcasts
Segmented / tiered / drip / online-trigger campaigns with preview and ROI.
Schedule & run
Expand a campaign into a planned send list. Worker is gated.
Inbox
The inbox is a read surface. It lists conversations newest-waiting first, lets you open a single thread live, and returns a suggested price for any fan you are about to charge.
No message bodies are stored
TEASE keeps conversation metadata — who is waiting, how long, unread counts, last activity — but never stores the text of a DM. The thread view re-reads messages live from your connected account each time you open it. This read is ban-safe by design.
List conversations
GET /api/admin/inbox/conversations
Returns your threads ordered so that fans waiting on a reply rise to the top (longest wait first), then everything else by most-recent activity.
curl "https://app.tease.link/api/admin/inbox/conversations?status=open&limit=100" \
-H "Authorization: Bearer $TEASE_API_KEY"const res = await fetch(
'https://app.tease.link/api/admin/inbox/conversations?status=open&limit=100',
{ headers: { Authorization: `Bearer ${process.env.TEASE_API_KEY}` } },
);
const { conversations, count } = await res.json();import os, requests
res = requests.get(
"https://app.tease.link/api/admin/inbox/conversations",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
params={"status": "open", "limit": 100},
)
data = res.json()Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
status | string | no | Filter by thread status (e.g. open). Omit for all. |
assigned_operator_id | int | no | Only threads assigned to this team member. |
limit | int | no | Max threads to return. 1–1000, default 200. |
Response
{
"conversations": [
{
"fan_id": "f_91kd2",
"of_account_id": "acct_main",
"platform": "of",
"last_msg_ts": 1751290800,
"last_inbound_ts": 1751290800,
"last_outbound_ts": 1751280000,
"unread_count": 2,
"assigned_operator_id": null,
"status": "open",
"waiting_seconds": 10800
}
],
"count": 1
}| Field | Type | Description |
|---|---|---|
fan_id | string | Stable identifier for the fan in this thread. |
platform | string | Source platform for the thread (e.g. of). |
last_msg_ts | int | Unix seconds of the most recent message either way. |
last_inbound_ts | int | Unix seconds of the fan's last message to you. |
last_outbound_ts | int | Unix seconds of your last message to the fan. |
unread_count | int | Unread inbound messages on the thread. |
assigned_operator_id | int | null | Team member assigned to the thread, if any. |
status | string | Thread status (e.g. open). |
waiting_seconds | int | Seconds the fan has been waiting on a reply; 0 when not waiting. |
The waiting-first SLA
A thread counts as waiting when it is open, has unread inbound, and the fan's last
message is newer than your last reply. waiting_seconds is how long that fan has been
left hanging — the single number your inbox is sorted by. Clear the longest-waiting fans
first and the SLA takes care of itself.
Read a thread
GET /api/admin/inbox/conversations/{fan_id}/messages
Opens one thread and returns its messages, read live from your connected account and normalized to a stable shape. Because bodies are not stored, this always reflects the true current state of the chat.
curl "https://app.tease.link/api/admin/inbox/conversations/f_91kd2/messages?limit=50" \
-H "Authorization: Bearer $TEASE_API_KEY"| Field | Type | Required | Description |
|---|---|---|---|
limit | int | no | Max messages to return. 1–100, default 50. |
Response
{
"fan_id": "f_91kd2",
"error": false,
"messages": [
{
"msg_id": "m_88210",
"ts": 1751290800,
"direction": "in",
"text": "hey is the new set out yet?",
"price_cents": 0,
"is_opened": true,
"can_purchase": false,
"media": []
}
]
}| Field | Type | Description |
|---|---|---|
msg_id | string | Message identifier. |
ts | int | Unix seconds the message was created. |
direction | string | in (from the fan) or out (from you). |
text | string | Message body, read live. Empty for media-only messages. |
price_cents | int | Price on a priced (PPV) message, in cents; 0 when free. |
is_opened | bool | Whether a priced message has been unlocked by the fan. |
can_purchase | bool | Whether the message is still purchasable. |
media | array | Attached media as { id, type }; empty when text-only. |
This endpoint never throws
A connected-account read can fail transiently. When it does, the response degrades to
{ "messages": [], "error": true, "fan_id": "…" } with a 200 — it never returns a
500, and it never falls back to another account's chat. Treat error: true as
"retry shortly", not as "no messages".
Price a fan
GET /api/admin/chatters/fan-pricing?fan_id={fan_id}
Returns a suggested PPV price and spend tier for one fan, derived from that fan's own purchase history on your account. The inbox calls this when you are about to send a priced message so the price fits the fan in front of you.
curl "https://app.tease.link/api/admin/chatters/fan-pricing?fan_id=f_91kd2" \
-H "Authorization: Bearer $TEASE_API_KEY"Response
{
"fan_id": "f_91kd2",
"tier": "medium",
"suggested_cents": 2000,
"min_cents": 1400,
"max_cents": 3200
}| Field | Type | Description |
|---|---|---|
tier | string | The fan's spend tier (new … whale). |
suggested_cents | int | Recommended PPV price for this fan, in cents. |
min_cents | int | Suggested floor for the price. |
max_cents | int | Suggested ceiling for the price. |
How the suggestion is produced
The suggested price is based on the fan's own spend, clamped to the allowed range. The
exact derivation is intentionally not part of the public contract — treat the returned
suggested_cents, min_cents, and max_cents as the interface and let them move as the
fan's history changes.
The fans-waiting queue
GET /api/admin/chatters/waiting
A team-oriented cut of the same SLA: open threads with unread fan messages, longest wait first, with whales prioritized so a high-value fan does not sit in the queue.
curl "https://app.tease.link/api/admin/chatters/waiting?min_wait_min=5&limit=50" \
-H "Authorization: Bearer $TEASE_API_KEY"| Field | Type | Required | Description |
|---|---|---|---|
min_wait_min | int | no | Only fans waiting at least this many minutes. Default 0. |
limit | int | no | Max fans to return. 1–500, default 50. |
Response
{
"waiting": 1,
"longest_wait_min": 180,
"fans": [
{
"fan_id": "f_91kd2",
"of_account_id": "acct_main",
"unread_count": 2,
"last_inbound_ts": 1751290800,
"minutes_waiting": 180,
"assigned_operator_id": null,
"lifetime_net_cents": 6400,
"tier": "medium"
}
]
}| Field | Type | Description |
|---|---|---|
waiting | int | Total fans currently waiting. |
longest_wait_min | int | The longest current wait, in minutes. |
fans | array | The waiting fans, longest-wait-first, whale-broken. |
Send a DM (Beta)
POST /api/admin/inbox/send
Contract only — live delivery is gated
This endpoint is a stable contract, but live delivery is off by default. Today it
screens your message and logs intent without sending anything — the effective mode is
shadow. Build against the request and response shapes now; when delivery is enabled, the
same call returns sent instead of shadow. Nothing in your integration changes.
Every call is screened, then routed through the gated engine. The response tells you exactly
what happened with a single status verdict:
status | Meaning |
|---|---|
blocked | The content screen refused the message. Nothing was routed and nothing was logged. |
shadow | The message passed the screen but live delivery is gated. Intent is logged; nothing was sent. This is today's default. |
sent | The message was delivered live and acknowledged. Only possible once the gate is on. |
curl -X POST https://app.tease.link/api/admin/inbox/send \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"fan_id": "f_91kd2",
"text": "new set just dropped — want it?",
"media_ids": ["vault_55012"],
"price_cents": 2000
}'Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
fan_id | string | yes | The fan to message. 1–64 chars. |
text | string | no | Message body. Up to 20,000 chars; empty for media-only. |
media_ids | string[] | no | Vault media to attach. |
price_cents | int | no | Price the message as PPV, in cents. Omit or 0 for a free DM. |
operator_id | int | no | Attribute the send to a team member. |
Response
{
"status": "shadow",
"firewall": {
"state": "ok",
"reasons": [],
"policy_version": "v3"
},
"outcome": {
"mode": "shadow",
"ok": true,
"result": "shadow"
}
}| Field | Type | Description |
|---|---|---|
status | string | The verdict: blocked, shadow, or sent. |
firewall.state | string | Whether the content screen passed (ok) or refused the message. |
firewall.reasons | array | Why the screen refused, when it did. Empty on a pass. |
outcome | object | Engine result for the send (mode, acknowledgement, status). |
What the content screen guarantees
The screen runs before anything is routed. A refusal stops the message cold — nothing is
queued, logged as sent, or attributed. The specific categories it enforces are part of the
ban-safety layer and are not published; what is guaranteed is that a blocked verdict
means the message never moves past the screen.
Broadcasts
A broadcast is a campaign: pick an audience, write a message, preview the reach, then schedule it into a paced send plan. You build and inspect campaigns through the API; the delivery worker that drains the plan is gated (see below).
Delivery mechanism
Set mechanism when you create a campaign — this is how the message is delivered:
| Mechanism | What it does | Trade-off |
|---|---|---|
1to1 (default) | Our paced per-fan tract — each fan gets an individual DM that lands in their Priority inbox. Enables {name} personalization, tier pricing and drip. | More requests; keep the pace/limits strict. |
native | One native OnlyFans mass message to lists/collections at once. | Cheap and safe, but no per-fan name/price/drip, and it lands in the fan's «All» tab. |
native is composed and previewed today; live delivery arrives with the sending engine
(scheduling a native campaign returns an honest «появится после подключения движка» until
then — it is never silently sent as 1to1).
Campaign modes
Set mode when you create a 1to1 campaign. The four modes shape how the audience is
selected and how sends are sequenced (they don't apply to native, which is one blast).
| Mode | What it does | Typical use |
|---|---|---|
segmented | One message to a single audience segment matched on fan attributes. | A drop announced to everyone who fits. |
tiered | The audience is split by spend tier so each tier can carry a tier-appropriate offer. | Price the same drop higher for whales. |
drip | A multi-step sequence with auto-stop: once a fan converts on an earlier step, their later steps are skipped. | A 3-touch nudge that stops the moment it lands. |
online_trigger | Sends are aligned to when fans are active rather than fired all at once. | Reach each fan at a moment they will see it. |
Create a campaign
POST /api/admin/broadcasts
curl -X POST https://app.tease.link/api/admin/broadcasts \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "June drop",
"mode": "drip",
"audience": { "tier": ["medium", "large", "whale"] },
"message": {
"steps": [
{ "text": "new set is live", "ppv_cents": 0 },
{ "text": "still time to grab it", "ppv_cents": 2500 }
]
},
"schedule": {}
}'const res = await fetch('https://app.tease.link/api/admin/broadcasts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TEASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: 'June drop',
mode: 'drip',
audience: { tier: ['medium', 'large', 'whale'] },
message: {
steps: [
{ text: 'new set is live', ppv_cents: 0 },
{ text: 'still time to grab it', ppv_cents: 2500 },
],
},
schedule: {},
}),
});
const { id } = await res.json();import os, requests
res = requests.post(
"https://app.tease.link/api/admin/broadcasts",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
json={
"title": "June drop",
"mode": "drip",
"audience": {"tier": ["medium", "large", "whale"]},
"message": {"steps": [
{"text": "new set is live", "ppv_cents": 0},
{"text": "still time to grab it", "ppv_cents": 2500},
]},
"schedule": {},
},
)
campaign_id = res.json()["id"]Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
title | string | yes | Campaign name. 1–255 chars. |
mode | string | no | segmented, tiered, drip, or online_trigger. Default segmented. |
audience | object | no | The audience spec (tier, segment, and other fan filters). |
message | object | no | The message body, or steps for a drip sequence. |
schedule | object | no | Timing hints for the planner. |
operator_id | int | no | Attribute the campaign to a team member. |
of_account_id | string | no | Target a specific connected account. |
The response is the new campaign id:
{ "id": 42 }List campaigns with GET /api/admin/broadcasts, and read one with
GET /api/admin/broadcasts/{campaign_id} (see ROI & the drip funnel).
Edit a draft with PATCH /api/admin/broadcasts/{campaign_id}.
Preview the audience
POST /api/admin/broadcasts/preview
Before you schedule, preview the reach. Preview resolves counts and a tier histogram only — it writes nothing and returns no PII: no fan ids, no names, just how many fans match.
curl -X POST https://app.tease.link/api/admin/broadcasts/preview \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "audience": { "tier": ["medium", "large", "whale"] } }'Response
{
"matched": 1840,
"selected": 1720,
"histogram": {
"medium": 1100,
"large": 480,
"whale": 140
}
}| Field | Type | Description |
|---|---|---|
matched | int | Fans matching the audience spec. |
selected | int | Fans that would actually receive a send after eligibility is applied. |
histogram | object | Selected fans bucketed by spend tier — the shape of the reach, without PII. |
Schedule a campaign (Beta)
POST /api/admin/broadcasts/{campaign_id}/schedule
Scheduling expands the campaign into a concrete, planned 1-on-1 send list and moves the
campaign to scheduled. It returns how many recipients and planned sends were produced.
The plan is built; the worker is gated
Scheduling is real — it materializes the planned sends — but the worker that delivers them is off by default (Beta). A scheduled campaign sits with its plan ready; nothing goes out until the delivery gate is enabled. You can build, preview, schedule, and inspect campaigns end-to-end today without anything being sent.
curl -X POST https://app.tease.link/api/admin/broadcasts/42/schedule \
-H "Authorization: Bearer $TEASE_API_KEY"Response
{
"campaign_id": 42,
"recipients": 1720,
"planned_sends": 3440,
"histogram": { "medium": 1100, "large": 480, "whale": 140 },
"start_ts": 1751299200
}| Field | Type | Description |
|---|---|---|
campaign_id | int | The scheduled campaign. |
recipients | int | Distinct fans in the plan. |
planned_sends | int | Total planned sends (more than recipients for multi-step drips). |
histogram | object | The recipient tier histogram. |
start_ts | int | Unix seconds the plan begins. |
A schedule call returns 409 if the campaign cannot be scheduled from its current status, or
if a priced message is missing its media.
Pause, resume, cancel
Once scheduled, a campaign is controllable:
| Endpoint | Effect |
|---|---|
POST /api/admin/broadcasts/{campaign_id}/pause | Halt a scheduled or running campaign. Its plan is preserved. |
POST /api/admin/broadcasts/{campaign_id}/resume | Resume a paused campaign back to running (or scheduled). |
POST /api/admin/broadcasts/{campaign_id}/cancel | Stop the campaign and drop every still-pending send. |
curl -X POST https://app.tease.link/api/admin/broadcasts/42/pause \
-H "Authorization: Bearer $TEASE_API_KEY"Each returns { "ok": true }, or 404 if the campaign does not exist in your account.
ROI & the drip funnel
GET /api/admin/broadcasts/{campaign_id}
Reading a campaign returns the full record plus two rollups: a send_status tally and a
per-step by_step funnel — exactly what you need to see a drip drop off step by step.
{
"id": 42,
"title": "June drop",
"mode": "drip",
"status": "running",
"recipients": 1720,
"sent": 1610,
"failed": 12,
"skipped": 98,
"purchases": 143,
"revenue_cents": 357500,
"send_status": {
"sent": 1610,
"pending": 0,
"failed": 12,
"skipped": 98
},
"by_step": [
{ "step": 0, "total": 1720, "sent": 1700, "failed": 8, "skipped": 12 },
{ "step": 1, "total": 1720, "sent": 1100, "failed": 4, "skipped": 616 }
]
}| Field | Type | Description |
|---|---|---|
status | string | draft, scheduled, running, paused, done, or cancelled. |
recipients | int | Distinct fans in the campaign. |
sent | int | Sends acknowledged. |
failed | int | Sends that errored. |
skipped | int | Sends dropped — e.g. a drip step skipped after the fan already converted. |
purchases | int | Conversions attributed to the campaign. |
revenue_cents | int | Revenue attributed to the campaign, in cents. |
send_status | object | Counts of every send by status. |
by_step | array | Per-step funnel: step, total, and per-status counts for each step. |
Reading the drip funnel
In a drip, watch skipped grow at each later step in by_step: those are fans who
converted earlier and were auto-stopped before the next nudge. A healthy drip lands most of
its revenue early and skips the rest — that is the auto-stop working, not lost reach.
What "ban-safe" means here
Every outbound path in this guide is built around one guarantee: you cannot accidentally
get your account flagged through TEASE. Qualitatively, that means sends are screened for
content before they move, delivered at a human-like pace with natural spacing, held back
during quiet hours, and routed through a gate that is off by default. The exact pacing,
timing, and screening rules are part of the safety layer and are deliberately not published —
the contract you build against is the verdict (blocked / shadow / sent) and the
campaign status, not the mechanism behind them.
Next steps
- Smart Links — trackable funnel links and per-link revenue.
- Attribution — revenue by source, country, and fan.
- Authentication — mint a scoped
al_live_*key. - API Reference — every inbox and broadcast endpoint.
Telegram gate bot
Your own turnstile bot in front of paid traffic — BotFather token connect, localized greeting, junk split to a channel, and automatic /start attribution.
Your stream address
One address for any encoder — OBS, Moblin, Larix, Streamlabs. We take the feed, show you a preview, and fan it out to the platforms when you say so.