Content vault
Index, search, and analyze your OnlyFans vault — find any media in a keystroke, see what actually sells, and get cohort-matched content recommendations.
The content vault is a searchable, AI-tagged index of every media item in your OnlyFans vault, plus the analytics that sit on top of it. Once your vault is indexed you can find any clip or set by title or tag in a keystroke, pull a whole folder as a bundle, and — far more valuable — read which content actually drives revenue, what is sitting dormant, where your demand outruns your supply, and which unseen pieces to send a given cohort next.
Every endpoint below lives under https://app.tease.link/api/admin/vault and is authenticated
with a Bearer al_live_* key. All reads require the read
scope; the scan trigger and auto-tag require write. Every response is JSON and is scoped to
the owner that minted the key — you only ever see your own vault.
The vault index is built and refreshed entirely on TEASE's side. You never upload media to us and you never have to maintain the index by hand — you trigger a scan, then read from it.
How it fits together
Index
Run a scan to build or refresh the searchable index of your vault.
Find
Search by title or tag, open a folder, or pull a whole drop as a bundle.
Analyze
Top sellers, content gaps, and dormant content — what to send, shelve, or shoot.
Recommend
Cohort-matched recommendations of unseen content, ranked for fit.
Scan and index the vault
Indexing is a background job. You trigger it, then poll status until it settles. A scan is idempotent — re-running it refreshes the index rather than duplicating it.
Trigger a scan
curl -X POST "https://app.tease.link/api/admin/vault/scan" \
-H "Authorization: Bearer $TEASE_API_KEY"The scan trigger takes no body or query parameters.
{ "started": true, "already_running": false }If a scan is already in flight the call is a no-op and you get { "started": false, "already_running": true }. If your OnlyFans account isn't connected yet, the call returns
400 with a message telling you to finish the connection first.
Poll status
curl "https://app.tease.link/api/admin/vault/scan/status" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"folders": 84,
"items": 5120,
"ai_tagged": 4870,
"last_indexed_ts": 1751280000,
"running": false,
"last_error": ""
}| Field | Type | Description |
|---|---|---|
folders | integer | Number of indexed folders/albums. |
items | integer | Number of indexed media items. |
ai_tagged | integer | How many items already carry AI tags. |
last_indexed_ts | integer | Unix seconds of the last successful index. 0 if never indexed. |
running | boolean | true while a scan is in flight — poll until it flips to false. |
last_error | string | Empty on success; a short message if the last scan failed. |
Indexing is a customer feature, not an API you assemble
How the index is built from your OnlyFans account, and how tags are derived, is handled entirely server-side. You are not expected to enumerate media or generate tags yourself — trigger the scan and read the results.
Search the vault
Instant title- and tag-search across the index. Results come back grouped by folder, with any loose (folderless) items separated out, so the panel's content picker can render a tree immediately.
curl "https://app.tease.link/api/admin/vault/search?q=lingerie&scope=all&limit=200" \
-H "Authorization: Bearer $TEASE_API_KEY"Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
q | string | no | Search text matched against titles and tags. Empty returns everything (up to limit). |
scope | string | no | all (default), sorted (only items filed into a folder), or unsorted (loose items). |
limit | integer | no | Max items to return, 1–1000. Defaults to 200. |
Response
{
"query": "lingerie",
"scope": "all",
"folders": [
{
"folder_id": "f_2291",
"name": "Red lingerie set",
"items": [
{
"media_id": "m_88120",
"media_type": "photo",
"folder_id": "f_2291",
"folder_name": "Red lingerie set",
"sorted": true,
"title": "red set — teaser",
"has_cover": true
}
]
}
],
"loose": [],
"counts": { "folders": 1, "items": 1 }
}| Field | Type | Description |
|---|---|---|
query | string | Echo of the search text. |
scope | string | Echo of the scope filter. |
folders | array | Matched items grouped by their folder. See the item shape below. |
loose | array | Matched items that aren't in any folder. |
counts.folders | integer | Number of folders in the result. |
counts.items | integer | Total matched items. |
Each item carries: media_id, media_type (photo, video, gif, …), folder_id,
folder_name, sorted (whether it's filed), title, and has_cover (whether a thumbnail is
available — see covers).
Search returns media_ids, never media bytes. Use the media_ids to compose a PPV in the
panel, or fetch a thumbnail from the cover endpoint.
Open a folder
Pull every item in one folder — the "select the whole pack" action. The response includes a
flat media_ids array ready to drop straight into a message composer.
curl "https://app.tease.link/api/admin/vault/folder/f_2291" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"folder_id": "f_2291",
"name": "Red lingerie set",
"media_ids": ["m_88120", "m_88121", "m_88122"],
"items": [
{ "media_id": "m_88120", "media_type": "photo", "title": "red set — teaser", "has_cover": true }
]
}| Field | Type | Description |
|---|---|---|
folder_id | string | The requested folder id. |
name | string | Folder name, or empty if unknown. |
media_ids | array | Every media id in the folder, in order. |
items | array | The full item record for each id (same shape as search). |
Pull a drop
A folder can also be read as a drop — its items grouped by role (video, photoset,
bts, other) with a ready-ordered bundle_media_ids for a one-tap "send the whole drop".
This is the natural unit a chatter sells.
curl "https://app.tease.link/api/admin/vault/drop/f_2291" \
-H "Authorization: Bearer $TEASE_API_KEY"{
"folder_id": "f_2291",
"name": "Red lingerie set",
"cover_media_id": "m_88120",
"roles": {
"video": { "count": 1, "media_ids": ["m_88130"] },
"photoset": { "count": 8, "media_ids": ["m_88120", "m_88121"] },
"bts": { "count": 2, "media_ids": ["m_88140", "m_88141"] },
"other": { "count": 0, "media_ids": [] }
},
"bundle_media_ids": ["m_88130", "m_88120", "m_88121", "m_88140", "m_88141"],
"counts": { "total": 11, "video": 1, "photoset": 8, "bts": 2, "other": 0 },
"is_drop": true
}| Field | Type | Description |
|---|---|---|
cover_media_id | string | Suggested cover for the drop. |
roles | object | Items split into video, photoset, bts, other, each with a count and ids. |
bundle_media_ids | array | The whole drop in send-order — drop straight into a composer. |
counts | object | total plus a per-role count. |
is_drop | boolean | An opaque flag TEASE sets when the folder qualifies as a drop. |
Covers
Fetch a thumbnail image for any media id. The endpoint returns a thumbnail image
(image/jpeg), so point an <img> at it directly — only items where has_cover is true
have one.
curl "https://app.tease.link/api/admin/vault/cover/m_88120" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-o cover.jpgReturns 404 when the media id has no cover. The endpoint only ever serves a thumbnail for a
media id in your own vault.
Auto-tag the backlog
auto-tag runs AI tagging over your untagged items so they become searchable and start
flowing into the performance, gaps, and recommendation analytics. AI tagging runs server-side.
It is an explicit action — it never sweeps your whole vault on its own — and it is
cost-capped and idempotent:
already-tagged items are skipped and never re-billed, and limit is clamped to a hard server
ceiling rather than rejected.
curl -X POST "https://app.tease.link/api/admin/vault/auto-tag" \
-H "Authorization: Bearer $TEASE_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "all_untagged": true, "limit": 100 }'const res = await fetch('https://app.tease.link/api/admin/vault/auto-tag', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TEASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ all_untagged: true, limit: 100 }),
});
const result = await res.json();res = requests.post(
"https://app.tease.link/api/admin/vault/auto-tag",
headers={"Authorization": f"Bearer {os.environ['TEASE_API_KEY']}"},
json={"all_untagged": True, "limit": 100},
)
result = res.json()Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
media_ids | string[] | no | Explicit ids to tag. Omit to use the backlog instead. |
all_untagged | boolean | no | When true, tag the untagged backlog up to limit. Defaults to false. |
limit | integer | no | Max items to tag in this call. Clamped to a hard server cap, so an oversized value is capped, not rejected. |
Response
{
"tagged": [{ "media_id": "m_88120", "tags": ["lingerie", "solo", "teaser"] }],
"tagged_count": 1,
"skipped": [{ "media_id": "m_88121", "reason": "already_tagged" }],
"skipped_count": 1,
"errors": [],
"capped": false,
"requested": 2
}| Field | Type | Description |
|---|---|---|
tagged | array | Items that got new tags, each with its media_id and the tags applied. |
tagged_count | integer | Count of newly tagged items. |
skipped | array | Items skipped, each with a reason (e.g. already tagged, nothing to tag). |
skipped_count | integer | Count of skipped items. |
errors | array | Per-item failures. A bad item degrades to an error here — it never 500s the whole call. |
capped | boolean | true if requested exceeded the cap and the run was trimmed. |
requested | integer | How many items the call attempted before capping. |
Tagging is degrade-safe per item: a single item that can't be tagged comes back in errors
or skipped with a reason; the rest of the batch still succeeds. Re-running is always safe —
tagged items are skipped, so you're never charged twice for the same media.
What sells — performance
performance ranks your vault by revenue: which individual media and which tags actually drive
purchases, each with an unlock rate (how often a fan who received it bought it). This is the
read behind "what's working" in the panel.
curl "https://app.tease.link/api/admin/vault/performance?limit=20" \
-H "Authorization: Bearer $TEASE_API_KEY"Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
from_ts | integer | no | Window start, Unix seconds. Defaults to 30 days ago. |
to_ts | integer | no | Window end, Unix seconds. Defaults to now. |
limit | integer | no | Max rows per list (media and tags), 1–200. Defaults to 20. |
Response
{
"range": { "from_ts": 1748688000, "to_ts": 1751280000 },
"media": [
{
"media_id": "m_88130",
"title": "red set — full video",
"media_type": "video",
"folder_name": "Red lingerie set",
"has_cover": true,
"tags": ["lingerie", "solo"],
"sent": 247,
"opened": 181,
"purchased": 89,
"revenue_cents": 142800,
"unlock_rate": 0.34
}
],
"tags": [
{ "tag": "lingerie", "sent": 988, "opened": 731, "purchased": 301, "revenue_cents": 461500, "unlock_rate": 0.29 }
],
"ppv_sends": 1320
}| Field | Type | Description |
|---|---|---|
range | object | The resolved from_ts/to_ts window. |
media | array | Top media by revenue. See the row fields below. |
tags | array | Top tags by revenue, with the same sent/opened/purchased/revenue_cents/unlock_rate. |
ppv_sends | integer | Total PPV sends counted in the window. |
Each media row carries media_id, title, media_type, folder_name, has_cover, tags,
and the performance numbers:
| Field | Type | Description |
|---|---|---|
sent | integer | Times this media was sent in the window. |
opened | integer | Times it was opened/viewed. |
purchased | integer | Times it was unlocked/bought. |
revenue_cents | integer | Net revenue it drove, in cents. |
unlock_rate | number | null | Share of recipients who unlocked it. null when too few sends to be meaningful. |
unlock_rate is reported as an outcome, already suppressed when the sample is too thin to
trust — you don't have to compute it or decide when it's reliable.
What to shoot next — gaps
gaps answers "what should I shoot?" by surfacing tags where demand outruns supply — themes
that sell well but where you have few items to send — alongside how much of your vault is still
unsorted or untagged.
curl "https://app.tease.link/api/admin/vault/gaps?limit=20" \
-H "Authorization: Bearer $TEASE_API_KEY"Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
from_ts | integer | no | Window start, Unix seconds. Defaults to 30 days ago. |
to_ts | integer | no | Window end, Unix seconds. Defaults to now. |
limit | integer | no | Max shoot_more rows, 1–200. Defaults to 20. |
Response
{
"range": { "from_ts": 1748688000, "to_ts": 1751280000 },
"shoot_more": [
{
"tag": "shower",
"revenue_cents": 88200,
"purchased": 61,
"sent": 140,
"rank": 1
}
],
"total_items": 5120,
"unsorted": 380,
"untagged": 250
}| Field | Type | Description |
|---|---|---|
range | object | The resolved window. |
shoot_more | array | Tags ranked as under-supplied for their demand — your shoot list. See the rows below. |
total_items | integer | Total indexed items. |
unsorted | integer | Items not yet filed into a folder. |
untagged | integer | Items with no AI tags yet — candidates for auto-tag. |
Each shoot_more row reports the tag's revenue_cents, purchased, sent, and its rank —
its position in the shoot list, where 1 is the most under-supplied for its demand. The list
arrives already ordered; you don't need to derive the ranking.
unsorted and untagged are a backlog signal: items there can't surface in search or
analytics until they're filed or tagged. Run auto-tag to clear the
untagged count.
What's gone cold — dormant
dormant returns vault items that have had zero send activity in a window — content that's
just sitting there and could be re-circulated. It's a set-difference between your vault and what
actually sold or was sent recently; nothing about your OnlyFans account net is touched.
curl "https://app.tease.link/api/admin/vault/dormant?window_days=30" \
-H "Authorization: Bearer $TEASE_API_KEY"Query parameters
| Field | Type | Required | Description |
|---|---|---|---|
window_days | integer | no | Look-back window, 1–3650 days. Defaults to 30. |
Response
{
"items": [
{
"media_id": "m_70011",
"title": "beach throwback",
"tags": ["outdoor", "bikini"],
"cover": true,
"last_sent": 1741000000
}
],
"count": 1,
"window_days": 30
}| Field | Type | Description |
|---|---|---|
items | array | Dormant media. Each has media_id, title, tags, cover, and last_sent. |
count | integer | Number of dormant items. |
window_days | integer | Echo of the window used. |
last_sent | integer | null | When the item was last sent (Unix seconds), or null if never. |
Cohort recommendations
recommend is the vault's most valuable read: given a cohort of fans, it returns unseen
content — items that cohort has not already been sent — ranked for how well they fit. Point it
at content (recommend for the people who bought a given piece) or describe a cohort with the
same audience filters the broadcast composer uses.
curl "https://app.tease.link/api/admin/vault/recommend?source=tiktok&rfm_tag=whale&limit=20" \
-H "Authorization: Bearer $TEASE_API_KEY"Query parameters
Provide either content_id (recommend for the cohort that bought that content) or one
or more audience filters. All parameters are optional and combine as an AND filter.
| Field | Type | Required | Description |
|---|---|---|---|
content_id | integer | no | Build the cohort from the buyers of this content. |
source | string | no | Filter the cohort by acquisition source (e.g. tiktok). |
country | string | no | Filter by country (ISO code). |
rfm_tag | string | no | Filter by RFM/lifecycle tag (e.g. whale, dormant). |
spend_min | integer | no | Minimum lifetime spend, in cents. |
spend_max | integer | no | Maximum lifetime spend, in cents. |
inactive_days | integer | no | Only fans inactive for at least this many days. |
bought_ppv | boolean | no | Restrict to fans who have (or haven't) bought PPV. |
new_vs_returning | string | no | new or returning. |
is_trial | boolean | no | Restrict to trial (or non-trial) fans. |
limit | integer | no | Max recommendations, 1–200. Defaults to 20. |
Response
{
"items": [
{
"media_id": "m_88130",
"title": "red set — full video",
"tags": ["lingerie", "solo"],
"cover": true,
"unlock_rate": 0.4,
"fit_reason": "Recommended for this cohort"
}
],
"cohort_summary": {
"filters": { "source": "tiktok", "rfm_tag": "whale" },
"source": "tiktok",
"fan_count": 312
},
"thin": false
}| Field | Type | Description |
|---|---|---|
items | array | Recommended unseen content, best fit first. See the row fields below. |
cohort_summary | object | The resolved cohort: the filters applied, dominant source, and fan_count. When built from content_id it also carries from_content_id and buyer_count. |
thin | boolean | true when the cohort has little buying signal, so the ranking is less certain. Treat the order as a weaker suggestion. |
Each recommendation row carries media_id, title, tags, cover, unlock_rate, and a
short, human-readable fit_reason you can show next to the suggestion.
The recommendation is an outcome, not a recipe
TEASE picks and ranks the unseen content for you — which signals feed the ranking, and how
they're weighed, is internal. You give it a cohort; it gives you an ordered list of what that
cohort hasn't seen and is most likely to buy. The thin flag tells you when to trust it less.
Next steps
Platform studio (connect + auto-chat)
Connect each webcam platform and set up auto-messages right inside its Stream tab — no need to visit their own dashboards.
Team access
Invite chatters, traffers, managers or trial viewers into your panel with per-tab checkboxes, a read-only mode and hidden money (percent-only view).