TEASEDocs
Guides

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 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

FieldTypeRequiredDescription
statusstringnoFilter by thread status (e.g. open). Omit for all.
assigned_operator_idintnoOnly threads assigned to this team member.
limitintnoMax 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
}
FieldTypeDescription
fan_idstringStable identifier for the fan in this thread.
platformstringSource platform for the thread (e.g. of).
last_msg_tsintUnix seconds of the most recent message either way.
last_inbound_tsintUnix seconds of the fan's last message to you.
last_outbound_tsintUnix seconds of your last message to the fan.
unread_countintUnread inbound messages on the thread.
assigned_operator_idint | nullTeam member assigned to the thread, if any.
statusstringThread status (e.g. open).
waiting_secondsintSeconds 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"
FieldTypeRequiredDescription
limitintnoMax 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": []
    }
  ]
}
FieldTypeDescription
msg_idstringMessage identifier.
tsintUnix seconds the message was created.
directionstringin (from the fan) or out (from you).
textstringMessage body, read live. Empty for media-only messages.
price_centsintPrice on a priced (PPV) message, in cents; 0 when free.
is_openedboolWhether a priced message has been unlocked by the fan.
can_purchaseboolWhether the message is still purchasable.
mediaarrayAttached 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
}
FieldTypeDescription
tierstringThe fan's spend tier (newwhale).
suggested_centsintRecommended PPV price for this fan, in cents.
min_centsintSuggested floor for the price.
max_centsintSuggested 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"
FieldTypeRequiredDescription
min_wait_minintnoOnly fans waiting at least this many minutes. Default 0.
limitintnoMax 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"
    }
  ]
}
FieldTypeDescription
waitingintTotal fans currently waiting.
longest_wait_minintThe longest current wait, in minutes.
fansarrayThe 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:

statusMeaning
blockedThe content screen refused the message. Nothing was routed and nothing was logged.
shadowThe message passed the screen but live delivery is gated. Intent is logged; nothing was sent. This is today's default.
sentThe 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

FieldTypeRequiredDescription
fan_idstringyesThe fan to message. 1–64 chars.
textstringnoMessage body. Up to 20,000 chars; empty for media-only.
media_idsstring[]noVault media to attach.
price_centsintnoPrice the message as PPV, in cents. Omit or 0 for a free DM.
operator_idintnoAttribute the send to a team member.

Response

{
  "status": "shadow",
  "firewall": {
    "state": "ok",
    "reasons": [],
    "policy_version": "v3"
  },
  "outcome": {
    "mode": "shadow",
    "ok": true,
    "result": "shadow"
  }
}
FieldTypeDescription
statusstringThe verdict: blocked, shadow, or sent.
firewall.statestringWhether the content screen passed (ok) or refused the message.
firewall.reasonsarrayWhy the screen refused, when it did. Empty on a pass.
outcomeobjectEngine 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:

MechanismWhat it doesTrade-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.
nativeOne 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).

ModeWhat it doesTypical use
segmentedOne message to a single audience segment matched on fan attributes.A drop announced to everyone who fits.
tieredThe audience is split by spend tier so each tier can carry a tier-appropriate offer.Price the same drop higher for whales.
dripA 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_triggerSends 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

FieldTypeRequiredDescription
titlestringyesCampaign name. 1–255 chars.
modestringnosegmented, tiered, drip, or online_trigger. Default segmented.
audienceobjectnoThe audience spec (tier, segment, and other fan filters).
messageobjectnoThe message body, or steps for a drip sequence.
scheduleobjectnoTiming hints for the planner.
operator_idintnoAttribute the campaign to a team member.
of_account_idstringnoTarget 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
  }
}
FieldTypeDescription
matchedintFans matching the audience spec.
selectedintFans that would actually receive a send after eligibility is applied.
histogramobjectSelected 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
}
FieldTypeDescription
campaign_idintThe scheduled campaign.
recipientsintDistinct fans in the plan.
planned_sendsintTotal planned sends (more than recipients for multi-step drips).
histogramobjectThe recipient tier histogram.
start_tsintUnix 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:

EndpointEffect
POST /api/admin/broadcasts/{campaign_id}/pauseHalt a scheduled or running campaign. Its plan is preserved.
POST /api/admin/broadcasts/{campaign_id}/resumeResume a paused campaign back to running (or scheduled).
POST /api/admin/broadcasts/{campaign_id}/cancelStop 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 }
  ]
}
FieldTypeDescription
statusstringdraft, scheduled, running, paused, done, or cancelled.
recipientsintDistinct fans in the campaign.
sentintSends acknowledged.
failedintSends that errored.
skippedintSends dropped — e.g. a drip step skipped after the fan already converted.
purchasesintConversions attributed to the campaign.
revenue_centsintRevenue attributed to the campaign, in cents.
send_statusobjectCounts of every send by status.
by_steparrayPer-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

On this page