# The Only API β guides
> Concepts and how-to guides for The Only API (theonlyapi.com): a REST API for OnlyFans and Fansly. The endpoint reference lives in llms-crm.txt and llms-of.txt.
---
# Introduction (/docs)
The Only API gives you programmatic access to OnlyFans and Fansly through a
single REST API and a single API key.
OnlyFans has no public API. Its private endpoints require **signed headers**
(`sign`, `time`, `app-token`) that rotate constantly, a live session cookie, and
a residential or datacenter proxy that matches the IP the session logged in
from. We do all of that server-side. You send one header.
Every connected account has a `platform` of either `onlyfans` or `fansly`. The
CRM routes work for both and return the same normalized shapes. The
`/api2/v2/*` passthrough is OnlyFans-only. See
[OnlyFans & Fansly](/docs/platforms).
## The shape of the API [#the-shape-of-the-api]
Your **CRM panel** is the tenant boundary. A panel has an ID (`crm_β¦`), an API
key, and a set of connected creator accounts β one slot per account, each with
its own persisted session and proxy.
Almost every path is scoped to a panel:
```
https://theonlyapi.com/api/crm/{crm_id}/β¦
```
There are two very different surfaces underneath that prefix, and knowing which
one you are on explains most surprising responses:
95 endpoints. OnlyFans and Fansly rows projected to the same shape, plus
server-side aggregations, cached reads, async jobs, webhooks, automations and
events that have no platform equivalent.
434 endpoints under `/api2/v2/*`. OnlyFans' own response body, unchanged,
wrapped in a small `{ success, status_code, data }` envelope.
Read [The two surfaces](/docs/two-surfaces) for how to choose between them.
## Start here [#start-here]
Panel β connected account β first authenticated call, in about five minutes.
Where keys live, and the difference between primary and secondary keys.
Session paste (recommended) or credentials with automatic 2FA handling.
The two envelopes, the real error shapes, and one wrapper that will confuse
you if you test without a key.
## Using these docs with an AI [#using-these-docs-with-an-ai]
Every page here has a Markdown twin: append `.md` to any URL, or use the
**Copy page** button in the page header to copy it, view it as Markdown, or open
it directly in ChatGPT or Claude. There is also an
[`llms.txt`](/llms.txt) index, per-section full-text bundles, and an MCP server
for the docs themselves.
See [Use these docs with an AI](/docs/ai-agents).
---
# Use these docs with an AI (/docs/ai-agents)
This documentation is built to be read by machines as well as people. Every page
has a plain-Markdown twin, the whole corpus is available as text bundles, and
there are two MCP servers β one for the docs, one for the API itself.
## Copy any page [#copy-any-page]
Every page header carries a **Copy Markdown** button and an **Open βΎ** menu:
| Action | What it does |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| **Copy Markdown** (button) | Puts the page's raw Markdown on your clipboard |
| **View as Markdown** | Opens the `.md` twin |
| **Open in ChatGPT / Claude / Cursor / Scira AI** | Opens that assistant pre-filled with *"Read \, I want to ask questions about it."* |
Those links carry a prompt asking the assistant to **fetch the page itself**, so they
only work if it has web access β and it will fetch the HTML, not the smaller `.md`
twin. For a guaranteed clean paste, use **Copy Markdown**.
## Markdown twins [#markdown-twins]
Append `.md` to any documentation URL:
```bash
curl https://docs.theonlyapi.com/docs/webhooks.md
curl https://docs.theonlyapi.com/docs/crm/messaging/send-a-dm-or-ppv.md
```
Content negotiation works too β request the normal URL with
`Accept: text/markdown` and you get Markdown back:
```bash
curl -H 'Accept: text/markdown' https://docs.theonlyapi.com/docs/pagination
```
The 529 endpoint pages have no prose body of their own β they are rendered from
the OpenAPI document. Their Markdown twins are generated from the same spec, so
each one arrives as a complete, self-contained brief: signature, description,
auth, parameters, request body, response shapes and a runnable `curl` example.
An agent can make a correct call from one page.
## Text bundles [#text-bundles]
| File | Contents |
| -------------------------------------- | ----------------------------------------------------- |
| [`/llms.txt`](/llms.txt) | Index of every page, with descriptions. Start here. |
| [`/llms-guides.txt`](/llms-guides.txt) | Every guide, full text. The one to paste into a chat. |
| [`/llms-crm.txt`](/llms-crm.txt) | All 95 CRM endpoints, full text. |
| [`/llms-of.txt`](/llms-of.txt) | All 434 OnlyFans passthrough endpoints, full text. |
| [`/llms-full.txt`](/llms-full.txt) | Everything. Large β for crawlers and indexers. |
| [`/agents.md`](/agents.md) | A short operating brief for coding agents. |
The split exists for a practical reason: `llms-full.txt` is over a megabyte,
which is more than most models want in one paste. `llms-guides.txt` is the file a
human should actually hand to an assistant.
```bash
# Give an assistant everything it needs to write an integration
curl https://docs.theonlyapi.com/llms-guides.txt
```
## MCP servers [#mcp-servers]
There are two, and they do different jobs. Connecting the wrong one is the usual
confusion.
`https://docs.theonlyapi.com/mcp` Β· public, no auth Β· search and read these
pages.
`https://theonlyapi.com/mcp` Β· OAuth 2.1 or a bearer API key Β· 59 tools that
act on your real panel.
### Docs MCP [#docs-mcp]
Read-only access to this documentation. No account, no key.
```bash
claude mcp add --transport http theonlyapi-docs https://docs.theonlyapi.com/mcp
```
```json
{
"mcpServers": {
"theonlyapi-docs": {
"type": "http",
"url": "https://docs.theonlyapi.com/mcp"
}
}
}
```
Tools:
| Tool | Purpose |
| ---------------- | ----------------------------------------------------------------------------------------- |
| `search_docs` | Full-text search across every page |
| `get_page` | Fetch one page as Markdown by path |
| `list_endpoints` | List API endpoints, filtered by surface, tag or method |
| `get_endpoint` | Full reference for one endpoint by method and path |
| `list_tags` | Every tag with its surface and endpoint count β cheap orientation before `list_endpoints` |
Use this when you want an agent to *learn* the API β answer questions, write
integration code, look up a parameter.
### API MCP [#api-mcp]
The product's own MCP server, which actually calls the API on your behalf: 59
tools across accounts, fans, messaging, earnings, subscribers, campaigns,
webhooks, automations and events.
```bash
claude mcp add --transport http theonlyapi https://theonlyapi.com/mcp
```
It supports OAuth 2.1 with PKCE, and clients that cannot do OAuth may send a CRM
API key as `Authorization: Bearer `.
Notable behaviours:
* **The destructive and money-moving tools require** `confirm=true` β sending a DM,
requesting a payout, changing subscription price, deleting an account, webhook or
automation, and any non-GET through the escape hatches. Lower-risk writes (tagging
a fan, creating a webhook or automation) apply immediately.
* **Non-GET proxying is off by default.** Enable `mcp_unsafe_proxy` in the
dashboard danger zone, or via
`PATCH /api/crm/{crm_id}/mcp/unsafe-proxy`, if you need the escape-hatch tools.
Note the toggle applies to static bearer-key clients only β OAuth sessions
(ChatGPT, Claude.ai) never get non-GET proxying.
* No *dedicated* vault, media-upload, mass-DM or scheduling tools exist β
`of_send_message` sends one text DM to one fan, with no price, media or recipient
list. Those surfaces are reachable only through the `of_crm_request` escape hatch,
which needs `confirm=true` for any non-GET.
Full setup instructions for ChatGPT, Claude, Cursor and Claude Code are on the
[MCP integration page](https://theonlyapi.com/integrations/chatgpt).
## The OpenAPI spec [#the-openapi-spec]
If your tooling speaks OpenAPI, skip the prose entirely:
```bash
curl https://theonlyapi.com/api/openapi.json
```
OpenAPI 3.1, 450 paths, 529 operations, every operation with a summary. This
reference is generated from it.
## Prompting suggestions [#prompting-suggestions]
* **Give it the guides, not the full bundle.** `llms-guides.txt` plus the two or
three endpoint pages you care about beats a megabyte of everything.
* **Name the surface.** "Use the CRM layer, not the `/api2/v2` passthrough" removes
most wrong answers, because the two have different response envelopes.
* **Warn it about the money rule.** Agents will happily write
`SUM(amount)` for earnings, which is wrong β see
[the chargeback trap](/docs/earnings).
* **Warn it about pagination.** The `/chats` and `/messages` quirks in
[Pagination](/docs/pagination) are not guessable; an agent will write a walker
that loses data unless it has read that page.
---
# Async jobs (/docs/async-jobs)
Anything that has to walk a platform API takes longer than a request should. Those
operations return `202 Accepted` immediately and run in the background.
## The pattern [#the-pattern]
### Start the job [#start-the-job]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" \
-H "X-API-Key: $KEY"
```
Returns `202` with the job or state payload.
### Watch it β poll or stream [#watch-it--poll-or-stream]
Poll the matching status route:
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh/status" \
-H "X-API-Key: $KEY"
```
The `/refresh/status` routes report **cache freshness**, not job progress β they
return `{success, cache: {β¦}}` with no `status` or `phase`. To see whether a job is
still running, use `GET /refresh/active`, or subscribe to
[server-sent events](/docs/streaming) and watch `refresh.progress` /
`refresh.complete`. The dashboard uses SSE.
### Read the result [#read-the-result]
The job writes into the cache, so the result is just the corresponding `/cached`
read β see [Cached reads](/docs/cached-reads). Exports instead produce a
downloadable ZIP.
## The jobs [#the-jobs]
| Start | Freshness check | What it does |
| -------------------------------------------------- | ------------------------------- | ----------------------------------------------------------- |
| `POST /accounts/{of_user_id}/subscribers/refresh` | `β¦/subscribers/refresh/status` | Delta-sync the subscriber cache |
| `POST /accounts/{of_user_id}/transactions/refresh` | `β¦/transactions/refresh/status` | Delta-sync the transaction cache |
| `POST /accounts/{of_user_id}/campaigns/refresh` | `β¦/campaigns/refresh/status` | Delta-sync campaigns and claimers |
| `POST /accounts/{of_user_id}/backfill` | via `/refresh/active` | Backfill historical data |
| `POST /accounts/{of_user_id}/exports` | `β¦/exports/{job_id}` | Build a data export ZIP β see [Data exports](/docs/exports) |
Two panel-wide helpers:
```
GET /refresh/active # every running refresh job in the panel
POST /accounts/{of_user_id}/refresh/{kind}/clear # clear a stuck job's state
```
## One at a time, per account per kind [#one-at-a-time-per-account-per-kind]
Starting a job while one of the same kind is already running is **not an error**.
You get `202` with `already_running: true` and the in-flight job β `state` for
refresh and backfill, `job` for exports.
```json
{ "success": true, "already_running": true, "state": { "β¦": "β¦" } }
```
Treat it as success. Retrying on it just spins.
A **stale** job is superseded. The two clocks differ, and it matters: a refresh is
abandoned after **10 minutes with no recorded progress** β a healthy long job
keeps resetting that clock, so it never looks stale β while an export is
abandoned **30 minutes after it started**, regardless of progress. The stale job
is marked failed and yours starts fresh, so a crashed worker never blocks you
permanently.
Exports behave the same way; only the payload key differs:
```json
{ "success": true, "already_running": true, "job": { "job_id": "f01152d3β¦", "status": "running" } }
```
Treat `already_running: true` as success β something is already doing the work you
wanted. No refresh or export route returns `409` for this; the `409`s that exist
are for a different class of mistake, like downloading an export before it has
finished.
## Don't poll tightly [#dont-poll-tightly]
Refresh jobs finish in seconds to minutes depending on account size; exports with
messages and media can take many minutes. Poll every few seconds at most, or use
SSE and stop polling entirely.
Status polls are **free** β they never touch the platform and carry no monthly
quota cost. They do count against the per-minute rate limit, so do not spin on
them.
---
# Authentication (/docs/authentication)
Send your key on every request:
```http
X-API-Key: YOUR_API_KEY
```
That is the whole scheme for API clients. There are no bearer tokens and no request
signing.
Two other schemes exist but are not available to you: OAuth 2.1, used only by the
[MCP server](/docs/ai-agents), and an internal `X-Service-Token` used by our own
services calling each other.
The API does not accept a key as a query parameter, even though one old error
message mentions it. Keys in URLs end up in access logs, proxy logs and browser
history.
## Keys are scoped to one panel [#keys-are-scoped-to-one-panel]
A key authenticates you as a specific CRM panel. Passing a key that is valid but
belongs to a **different** panel than the `{crm_id}` in the path is rejected the
same way an invalid key is β there is no cross-panel access.
Likewise, `{of_user_id}` must be an account connected to *your* panel:
```json
{ "error": "Account not found or does not belong to this CRM panel" }
```
Most routes answer that with a `403`, and it means the same thing whether the
account does not exist or simply is not yours.
A handful answer the same condition with `404 {"error": "Account not found"}`
instead β `DELETE /accounts/{of_user_id}`, the `/proxy` read and update, the
`/polling` read, and `/fansly-credentials`. Treat `403` and `404` on an account
path as the same thing: not yours.
## Unscoped routes [#unscoped-routes]
These seven carry no `{crm_id}` and are served by
`https://api.theonlyapi.com` β not `theonlyapi.com`, which does not route them.
"Unscoped" is not the same as "open": most still require something.
| Route | What it needs |
| ----------------------------------------- | ----------------------------------------------------------------------------- |
| `GET /health` | nothing |
| `POST /api/auth/login` | nothing (the credentials *are* the auth) |
| `POST /api/auth/start-email-verification` | nothing |
| `POST /api/auth/verify-email` | nothing |
| `GET /api/whoami` | **an API key** β it resolves a key to its panel, it is just not scoped by one |
| `POST /api/auth/register` | **a captcha** β see below |
| `POST /api/crm/register` | **a captcha** β see below |
See [Base URL & panels](/docs/base-url) for why the host differs.
The two registration routes require either a Cloudflare Turnstile token
(`captcha_token` in the body, or an `X-Captcha-Token` header) or an internal
service token. Without one they return:
```json
{ "success": false, "code": "CAPTCHA_REQUIRED",
"error": "Captcha verification is required to create an account.", "reason": "missing_token" }
```
Sign up through [the website](https://theonlyapi.com/pricing), which solves the
captcha for you β see [Get an API key](/docs/get-an-api-key). There is no need to
call either route yourself.
## Failure codes [#failure-codes]
| Code | Body | What it means |
| ----- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `403` | `{"error": "Invalid API key"}` | Key is unknown, revoked, or belongs to another panel |
| `401` | `{"error": "Missing X-API-Key headerβ¦"}` | No key sent β note **401**, while an *invalid* key is 403 |
| `403` | `{"error": "Account not found or does not belong to this CRM panel"}` | `of_user_id` is not connected to your panel |
| `403` | `{"error": "β¦"}` | A secondary key attempted a primary-key-only operation |
| `429` | `{"error": "Rate limit exceeded", "retry_after": "1000 per 1 minute"}` | Per-minute limit. `retry_after` echoes the limit that fired, **not** a number of seconds β use the `Retry-After` header for the delay |
| `429` | `{"error": "API call limit reached", "plan": β¦, "limit": N}` | Monthly quota exhausted |
| `403` | `{"code": "WRITES_DISABLED"}` | The account has `allow_of_write_actions` off β see [Connect an account](/docs/connect-an-account) |
| `403` | `{"code": "SLOT_LIMIT"}` | Connecting an account beyond your plan's slots |
| `403` | `{"code": "CAPTCHA_REQUIRED"}` | A registration route called without a captcha |
| `404` | `{"error": "Account not found"}` | Same as the `403` above, on the few routes noted earlier |
| `501` | `{"code": "platform_not_supported", "platform": β¦, "feature": β¦}` | The operation does not exist for that account's platform β see [OnlyFans & Fansly](/docs/platforms) |
Invalid, revoked and wrong-panel keys all return the same `403`. Key comparison
is constant-time, and unknown keys still run a dummy comparison, so response
timing does not reveal whether a key exists.
---
# Automations (/docs/automations)
An automation is a rule stored on your panel: when an event of a given type
arrives and every condition matches, run an action. No infrastructure needed on
your side.
```bash
curl -X POST "$BASE/api/crm/$CRM/automations" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Thank big tippers on Discord",
"trigger_event": "new_tip",
"conditions": [
{ "field": "payload.amount", "op": "gt", "value": 20 }
],
"action_type": "discord",
"action_params": {
"url": "https://discord.com/api/webhooks/β¦",
"message": "πΈ {payload.fan.username} tipped ${payload.amount}"
},
"is_active": true
}'
```
## Conditions [#conditions]
A list, joined with **AND**. Every condition must match for the action to run. An
empty list matches every event of that type.
```json
{
"conditions": [
{ "field": "payload.amount", "op": "gt", "value": 5 },
{ "field": "payload.fan.username", "op": "neq", "value": "banned_user" }
]
}
```
`field` is a dotted path into the [event envelope](/docs/events), so
`event_type`, `of_user_id`, `occurred_at` and anything under `payload.` are all
addressable.
### Operators [#operators]
| `op` | Meaning |
| ------------ | ------------------------------------------------ |
| `eq` | equal |
| `neq` | not equal |
| `gt` / `gte` | greater than / or equal |
| `lt` / `lte` | less than / or equal |
| `contains` | substring |
| `startswith` | prefix |
| `in` | the field's value appears in the list you supply |
## Actions [#actions]
| `action_type` | `action_params` |
| ------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `webhook` | `{ "url", "method"?, "headers"?, "body"? }` β **unsigned and un-retried**, unlike a [first-party webhook](/docs/webhooks) |
| `discord` | `{ "url", "message", "username"? }` |
| `slack` | `{ "url", "message" }` |
| `telegram` | `{ "bot_token", "chat_id", "message" }` |
| `send_dm` | `{ "message", "to_fan_id"? }` β defaults to `payload.fan.id` |
| `tag_fan` | `{ "tag": "vip" }` |
### Templating [#templating]
Any `message` supports `{dotted.path}` interpolation against the event, resolved at
dispatch:
```
"πΈ {payload.fan.username} tipped ${payload.amount} on {occurred_at}"
```
Templating applies to **every string** in `action_params` β `url`, `tag`,
`to_fan_id`, nested objects and arrays β not just `message`. Credential keys
(`bot_token`, `secret`, `token`, `api_key`, `password`) are deliberately excluded
and passed through byte-for-byte.
An unresolvable path renders as an **empty string**, not as literal `{β¦}`. A typo
silently leaves a hole in the message (`"πΈ tipped $"`) rather than announcing
itself β check the first delivery.
`send_dm` is an OnlyFans write and is gated per account by
`allow_of_write_actions`. Enable it via
`PATCH /accounts/{of_user_id}/polling`. Otherwise the action is logged as a
`failed` run in `GET /automations/{id}/runs` with
`error_snippet: "send_dm blocked: account has allow_of_write_actions=false"`.
There is no HTTP error to observe β automations run in the event fan-out, not in a
request. See [Connect an account](/docs/connect-an-account).
## Managing automations [#managing-automations]
```
GET /automations # list
POST /automations # create
GET /automations/{id} # read one
PATCH /automations/{id} # update (send {"is_active": false} to pause)
DELETE /automations/{id}
POST /automations/{id}/run-now # fire the action immediately, for testing
GET /automations/{id}/runs # run history
```
`run-now` validates an action without waiting for a real event β but it builds a
synthetic event from the `sample_payload` you supply, so **send one**. Without it
the payload is empty, every `payload.*` condition fails, and the run is recorded as
`skipped`.
```bash
curl -X POST "$BASE/api/crm/$CRM/automations/$ID/run-now" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"sample_payload": {"amount": 25, "fan": {"id": "987", "username": "somefan"}}}'
```
The response is `{"status": "success" | "skipped" | "failed"}`. `GET /runs`
(default 50, max 200) has the history.
`POST /automations` also accepts `of_user_id` to scope a rule to one connected
account β the per-account routing that [webhooks](/docs/webhooks) cannot do.
An unknown or misspelled `op` is accepted, then fails every comparison at dispatch,
so the automation silently never fires. Check `/runs` after creating a rule.
## Worked examples [#worked-examples]
```json
{
"name": "VIP tagging",
"trigger_event": "new_purchase",
"conditions": [{ "field": "payload.amount", "op": "gte", "value": 100 }],
"action_type": "tag_fan",
"action_params": { "tag": "vip" }
}
```
The tag then shows up in `GET /fans`, so you can segment on it later β including
as a mass-DM audience filter. See [Messaging](/docs/messaging).
```json
{
"name": "Account needs reconnecting",
"trigger_event": "polling_paused",
"conditions": [],
"action_type": "telegram",
"action_params": {
"bot_token": "123456:ABCβ¦",
"chat_id": "-1001234567890",
"message": "β οΈ Polling paused for {of_user_id} β {payload.reason} ({payload.failures} failures)"
}
}
```
This is the single most useful automation to set up first. It is the earliest
reliable signal that a session has died β see [Sessions](/docs/sessions).
```json
{
"name": "Welcome DM",
"trigger_event": "new_subscriber",
"conditions": [],
"action_type": "send_dm",
"action_params": {
"message": "Hey {payload.fan.display_name}, thanks for subscribing π",
"to_fan_id": "{payload.fan.id}"
}
}
```
Note `to_fan_id` is templated from the event, so one rule covers every new
subscriber.
## Automations, webhooks, or your own code? [#automations-webhooks-or-your-own-code]
Automations are the right tool when the reaction is a notification or a simple
one-step side effect. Once you need branching logic, external data, or state, use a
[webhook](/docs/webhooks) and do the work in your own service.
Both run from the same event emission, so you can use them together β an automation
for the Discord ping, a webhook for your database.
---
# Base URL & panels (/docs/base-url)
```
https://theonlyapi.com/api/crm/{crm_id}
```
`{crm_id}` is your panel ID β a string like `crm_0123456789abcdef`, shown in
**Dashboard β Settings**. If you only have an API key, resolve it:
```bash
curl "https://api.theonlyapi.com/api/whoami" -H "X-API-Key: $KEY"
```
## The panel is the tenant boundary [#the-panel-is-the-tenant-boundary]
A panel owns:
* its API keys (one primary, any number of secondary)
* its connected creator accounts β one **slot** per account, each with its own
persisted session and proxy
* its events, webhooks, automations, exports and cached data
Nothing crosses panels. A key for panel A cannot read panel B, and
`{of_user_id}` must belong to the panel in the path.
## Unscoped routes use a different host [#unscoped-routes-use-a-different-host]
Seven routes are not panel-scoped. They live on the **API host**, not the
marketing host:
```
https://api.theonlyapi.com/health
https://api.theonlyapi.com/api/whoami
https://api.theonlyapi.com/api/auth/register
https://api.theonlyapi.com/api/auth/login
https://api.theonlyapi.com/api/auth/start-email-verification
https://api.theonlyapi.com/api/auth/verify-email
https://api.theonlyapi.com/api/crm/register
```
`theonlyapi.com` is a Next.js app that proxies `/api/crm/{crm_id}/β¦` through to
the API. It does **not** route the unscoped paths, and two of them collide with
its own routes:
| Route on `theonlyapi.com` | What you actually get |
| ------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `GET /health` | `404` |
| `GET /api/whoami` | `404` |
| `POST /api/crm/register` | `{"error": "Unauthorized"}` |
| `POST /api/auth/*` | `Error: This action with HTTP POST is not supported by NextAuth.js` β the site's own auth handler answers first |
Use `api.theonlyapi.com` for all seven.
In this reference those endpoints are documented with their full path, and their
"Test it" server is `api.theonlyapi.com` rather than the panel-scoped base URL.
## Path anatomy [#path-anatomy]
```
https://theonlyapi.com/api/crm/crm_abc123/accounts/482687148/chats
βββββββββ origin βββββββ panel scope ββββββββ resource ββββββββββ
```
Most resources hang off a connected account:
```
/accounts/{of_user_id}/balances
/accounts/{of_user_id}/subscribers/cached
/accounts/{of_user_id}/chats/{with_user_id}/messages
```
A few are panel-wide, aggregating across every connected account:
```
/fans
/balances/summary
/earnings/summary
/events
/webhooks
/automations
```
And the passthrough mirrors OnlyFans' own paths verbatim after the prefix:
```
/api2/v2/users/me
/api2/v2/subscriptions/subscribers?limit=10&type=active
```
Despite the name, `of_user_id` identifies a connected account on **either**
platform. A Fansly account's ID is returned and used as `of_user_id` too. Get
the list from `GET /accounts`.
## Which host to use [#which-host-to-use]
| Routes | Host |
| ----------------------------------------------- | ---------------------------- |
| Everything panel-scoped β `/api/crm/{crm_id}/β¦` | `https://theonlyapi.com` |
| The seven unscoped routes above | `https://api.theonlyapi.com` |
`api.theonlyapi.com` reaches the API directly and serves the panel-scoped routes
too, so it works for both if you would rather use one host everywhere. The
panel-scoped examples in these docs use `theonlyapi.com` because that is the
documented product base URL.
---
# Bulk account import (/docs/bulk-import)
Connecting accounts one at a time is fine for a handful. For an agency onboarding
dozens, paste them all at once: the importer parses the paste, tells you exactly
what will happen before touching anything, then works through the rows in the
background β parking any that need a 2FA code until you supply one.
All eight routes are panel-scoped and authenticated like every other CRM route β
see the [Bulk Import reference](/docs/crm/bulk-import) for full request and
response schemas.
## The flow [#the-flow]
### Preview β no side effects [#preview--no-side-effects]
```bash
curl -X POST "$BASE/api/crm/$CRM/import/preview" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "creator1@example.com,hunter2,http://user:pass@host:port\ncreator2@example.com,hunter3",
"default_platform": "onlyfans"
}'
```
Nothing is written and no login is attempted. You get back what the importer
*would* do: the detected format, which rows are valid, which lane each takes, and
precisely why row 47 is bad.
```json
{
"success": true,
"format": "bare",
"delimiter": ",",
"has_header": false,
"columns": ["email", "password", "proxy"],
"total": 2,
"valid_count": 2,
"invalid_count": 0,
"rows": [
{
"row_index": 0,
"email": "creator1@example.com",
"platform": "onlyfans",
"lane": "password",
"proxy": "host:port",
"has_password": true,
"has_totp_secret": false,
"cookie_fields": [],
"valid": true,
"errors": []
}
]
}
```
Passwords and TOTP secrets come back as booleans (`has_password`,
`has_totp_secret`), cookies as a list of field *names*, and the proxy as
`host:port` only β the credentials are stripped. The paste already crossed the
network once; reflecting it back would double the exposure for no benefit.
Always preview first. It is free, it costs no platform requests, and it is the
only chance to catch a malformed paste before logins start.
### Create the job [#create-the-job]
Same body, plus an optional `source`:
```bash
curl -X POST "$BASE/api/crm/$CRM/import/jobs" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"text": "β¦", "default_platform": "onlyfans", "source": "paste"}'
```
Returns `202` with the job, and dispatches a background worker. Up to
**1,000 rows** per job by default.
### Watch it [#watch-it]
Progress arrives on the [event stream](/docs/streaming) as `import.progress`
(coalesced to at most one per second) and finishes with `import.complete`:
```bash
curl -N "$BASE/api/crm/$CRM/events/stream" -H "X-API-Key: $KEY"
```
Or poll:
```bash
curl "$BASE/api/crm/$CRM/import/jobs/$JOB_ID" -H "X-API-Key: $KEY" # one job
curl "$BASE/api/crm/$CRM/import/jobs" -H "X-API-Key: $KEY" # history
```
### Answer the 2FA prompts [#answer-the-2fa-prompts]
Rows whose platform demands a code **park** rather than fail, with status
`needs_2fa`. Supply the code and that row finishes:
```bash
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/rows/$ROW_ID/otp" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"code": "123456"}'
```
This call is **synchronous** β one platform call, and the response carries the
real outcome rather than "queued", because someone is waiting on it.
## Parked rows outlive the job [#parked-rows-outlive-the-job]
```bash
curl "$BASE/api/crm/$CRM/import/pending-2fa?limit=200" -H "X-API-Key: $KEY"
```
Panel-wide, across every job. This is what lets a dashboard say "3 accounts need
a 2FA code" long after whoever started the import has closed the tab β a per-job
query could not answer that.
## Retry and cancel [#retry-and-cancel]
```bash
# Retry one failed row
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/rows/$ROW_ID/retry" \
-H "X-API-Key: $KEY"
# Stop the whole job
curl -X POST "$BASE/api/crm/$CRM/import/jobs/$JOB_ID/cancel" -H "X-API-Key: $KEY"
```
## Lanes [#lanes]
Every valid row is routed down one of two lanes, decided by what you supplied:
| Lane | Triggered by | Behaviour |
| ---------- | -------------------------------------- | ----------------------------------------------- |
| `cookie` | Session cookies or a Fansly auth token | Session paste β no login attempt, most reliable |
| `password` | An email/username and password | Full login flow, may park on 2FA |
Prefer `cookie` rows where you have them, for the same reasons given in
[Connect an account](/docs/connect-an-account).
## Row statuses [#row-statuses]
| Status | Meaning |
| ------------------- | ---------------------------------------------------------------------------------- |
| `pending` | Parsed and accepted, not yet claimed |
| `running` | Login in flight |
| `needs_2fa` | Parked β waiting for you to post a code |
| `needs_2fa_expired` | The 2FA window closed and the credential was destroyed; `retry` restarts the login |
| `success` | Account connected |
| `failed` | Gave up; see the row's error |
| `invalid` | Failed parse validation β never attempted |
| `skipped` | That email is already connected to this panel |
| `slot_exhausted` | Plan account limit reached |
| `canceled` | Job canceled before this row ran |
There is no `queued` and no `complete` β success is `success`. The authoritative
list comes back as `row_states` on `GET /import/jobs/{job_id}`.
## Endpoints [#endpoints]
| Endpoint | What it does |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------- |
| [`POST /import/preview`](/docs/crm/bulk-import/preview-an-import-paste) | Parse + validate, no side effects |
| [`POST /import/jobs`](/docs/crm/bulk-import/start-a-bulk-import) | Create a job β `202` |
| [`GET /import/jobs`](/docs/crm/bulk-import/list-import-jobs) | Job history |
| [`GET /import/jobs/{job_id}`](/docs/crm/bulk-import/get-an-import-job) | One job, with rows |
| [`POST /import/jobs/{job_id}/cancel`](/docs/crm/bulk-import/cancel-an-import-job) | Stop a running job |
| [`POST /import/jobs/{job_id}/rows/{row_id}/otp`](/docs/crm/bulk-import/supply-a-2fa-code-for-an-import-row) | Supply a 2FA code (synchronous) |
| [`POST /import/jobs/{job_id}/rows/{row_id}/retry`](/docs/crm/bulk-import/retry-an-import-row) | Retry one row |
| [`GET /import/pending-2fa`](/docs/crm/bulk-import/list-rows-waiting-on-a-2fa-code) | Panel-wide parked rows |
All writes are on the 100/minute sensitive-route limit.
Each connected account consumes a slot. A row that hits the cap ends as
`slot_exhausted` (not `failed`) with `Account limit reached (N). Buy a slot to
add another account.` and `code: "SLOT_LIMIT"`.
The check is a read, not a reservation, so up to the lane concurrency (10 cookie /
6 password) rows can all observe the same last free slot and proceed β a panel can
end up slightly over its cap. Check `GET /api/crm/{crm_id}/usage` first.
---
# Cached reads (/docs/cached-reads)
Every live read reaches OnlyFans or Fansly and counts against **their** per-account
rate limits β the ones that get accounts flagged. The `/cached` routes serve from
our own store instead and cost **zero** platform requests.
For anything you read repeatedly β dashboards, reports, reconciliation, any loop β
use the cached route.
## The cached routes [#the-cached-routes]
| Route | Returns |
| -------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `GET /accounts/{of_user_id}/subscribers/cached` | Subscriber snapshot, paginated |
| `GET /accounts/{of_user_id}/subscribers/new` | Newly seen subscribers |
| `GET /accounts/{of_user_id}/subscribers/stats` | Aggregated counts by `granularity` (`hour`/`day`/`week`/`month`) |
| `GET /accounts/{of_user_id}/transactions/cached` | Transaction rows, paginated |
| `GET /accounts/{of_user_id}/fans/{fan_id}/transactions/cached` | One fan's transactions |
| `GET /accounts/{of_user_id}/campaigns/{campaign_id}/claimers/cached` | Campaign claimers |
All of them except `/subscribers/stats` use offset pagination with a trustworthy
`total` β convention 1 in [Pagination](/docs/pagination). Note the rows come back
under a generic `list` key, not one named after the resource.
`/subscribers/stats` is not paginated at all: it returns a zero-filled `buckets`
array plus `total_in_window`.
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/cached?limit=100&offset=0" \
-H "X-API-Key: $KEY"
```
## Keeping the cache fresh [#keeping-the-cache-fresh]
Two things update it.
**Background polling.** Connected accounts are polled on a schedule. This is what
generates [events](/docs/events) β new tips, subscribers, messages, balance
changes β and it keeps the caches moving without you asking.
**Explicit refresh.** When you need current data right now, kick off an
[async refresh job](/docs/async-jobs):
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" \
-H "X-API-Key: $KEY"
# 202 β poll .../subscribers/refresh/status
```
Refreshes are **delta syncs** β they fetch what changed rather than re-walking
everything, so they are cheap enough to run on a schedule.
For history that predates the account being connected, use
`POST /accounts/{of_user_id}/backfill`.
## A sensible read pattern [#a-sensible-read-pattern]
```python
# 1. Trigger a refresh only when your data is older than you can tolerate.
if cache_age_seconds > 300:
session.post(f"{BASE}/api/crm/{CRM}/accounts/{OFUID}/subscribers/refresh",
headers={"X-API-Key": KEY})
# 2. Read from the cache regardless β it always answers, and costs nothing
# against the platform. The refresh will land shortly.
rows = list(walk(session,
f"{BASE}/api/crm/{CRM}/accounts/{OFUID}/subscribers/cached",
KEY, item_key="list"))
```
Do not block on the refresh finishing before reading. Read the cache, and let
either the next refresh or the background poller catch you up. If you need to know
the moment new data lands, subscribe to [events](/docs/streaming) instead of
polling status.
`GET` and `PATCH /accounts/{of_user_id}/polling` control what we poll for an
account, and how often. The same object holds `allow_of_write_actions`, the gate
on OnlyFans write operations β see [Connect an account](/docs/connect-an-account).
## When to read live instead [#when-to-read-live-instead]
Reach for the live route when staleness is genuinely unacceptable β confirming a
balance right before a payout, or checking whether a specific message landed. Live
routes are `GET /accounts/{of_user_id}/subscribers`, `/balances`, `/earnings`,
`/purchases`, `/chats`, and the whole `/api2/v2/*` passthrough.
Even then, do it once, not in a loop.
---
# Connect an account (/docs/connect-an-account)
Before you can read or write anything for a creator, that account has to be
connected to your panel. Each connected account occupies one **slot**.
There are two ways in. Session paste is more reliable and is what we recommend.
## Option A β session paste (recommended) [#option-a--session-paste-recommended]
Open a browser logged into OnlyFans, then **DevTools β Application β Cookies β onlyfans.com** and copy `sess` and `auth_id` (and `fp` if present).
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
-H "Content-Type: application/json" \
-d '{
"platform": "onlyfans",
"sess": "",
"auth_id": "",
"fp": ""
}'
```
Omitting `X-Proxy` does **not** return an error. The login proceeds and egresses on
our shared server IP, and the saved session records **no** proxy β so every later
request for that account also goes out direct. The failure surfaces later, as a
session that keeps breaking. A malformed proxy is silently downgraded to none rather
than rejected, so a typo degrades to direct egress with no signal.
OnlyFans ties sessions to the IP that created them. See [Proxies](/docs/proxies).
Paste an auth token rather than cookies. `X-Proxy` is optional.
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "fansly",
"auth_token": "",
"fansly_session_id": "",
"fansly_client_id": ""
}'
```
The session is stored server-side. You never pass cookies or tokens again β from
here on, the `of_user_id` is enough.
## Option B β credentials [#option-b--credentials]
We run the whole login flow for you: Cloudflare init, Turnstile solve, signed
login request, and a 2FA prompt if the platform asks for one.
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
-H "Content-Type: application/json" \
-d '{
"platform": "onlyfans",
"email": "creator@example.com",
"password": "β¦",
"use_captcha": true
}'
```
For `platform: "fansly"` the `email` field accepts a **username or an email**,
`X-Proxy` is optional, and `use_captcha` is ignored β Fansly's login has no captcha
step.
### Two-factor authentication [#two-factor-authentication]
If the platform demands 2FA, the response includes `requires_2fa: true` (Fansly
additionally returns `twofa_type`). Submit the code to
`POST /accounts/login/verify-otp`, passing the **same** `platform`, the **same**
identifier you logged in with. You do **not** need to resend `X-Proxy` β the proxy
stored with the parked 2FA challenge is reused, and the header is ignored here:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/verify-otp" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
-H "Content-Type: application/json" \
-d '{"platform": "onlyfans", "email": "creator@example.com", "otp_code": "123456"}'
```
This route reads `otp_code` only β sending `code` returns
`400 OTP code required`. (The unrelated
[bulk-import](/docs/bulk-import) OTP route accepts either spelling, which makes
the inconsistency easy to trip over.)
Each of the three connection routes gets its own allowance of **20 requests per minute**. They are counted separately, so in one minute you could paste 20 sessions
*and* attempt 20 credential logins *and* submit 20 OTP codes.
The count is per API key, so it is your panel's budget alone β other customers
cannot use it up.
Going over returns `429` until the minute rolls over. The practical consequence: do
not retry a failed login in a loop. Twenty quick retries and you have spent the
allowance and locked yourself out of connecting anything for the rest of that minute.
## Connecting many at once [#connecting-many-at-once]
For more than a handful of accounts, paste them all and let the importer work
through them β including parking rows that need a 2FA code until you supply one.
See [Bulk account import](/docs/bulk-import).
## After connecting [#after-connecting]
`GET /accounts` lists connected accounts and their IDs:
```bash
curl "$BASE/api/crm/$CRM/accounts" -H "X-API-Key: $KEY"
```
Add `?include_session=true` and each **OnlyFans** entry also carries a `session`
block with `sess`, `auth_id` and `proxy` β present only when a stored session file
exists; a read failure yields `session_error` instead. Fansly accounts never get one;
use `GET /accounts/{of_user_id}/fansly-credentials` for those. You rarely need either β
passing `of_user_id` and letting us resolve the session is the point.
## Disconnecting [#disconnecting]
```bash
curl -X DELETE "$BASE/api/crm/$CRM/accounts/$OFUID" -H "X-API-Key: $KEY"
```
This frees the slot. Add `?purge=true` to also delete the account's retained
data rather than keeping it.
## Write actions are off until you enable them [#write-actions-are-off-until-you-enable-them]
Connecting an account does not by itself allow us to *act* as it. Every write
performed as the account is gated by the `allow_of_write_actions` polling setting:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"allow_of_write_actions": true}'
```
Without it, writes return `403` with `code: "WRITES_DISABLED"`. Match on the code,
not the message.
The gate now covers **every OnlyFans or Fansly write performed as the account**:
single DM/PPV sends (both platforms), the mass-DM real send, the `send_dm`
automation action, payout requests, subscription-price changes, creating a campaign,
the raw `POST /accounts/{of_user_id}/request` proxy when its body `method` is not
`GET`, and **any non-GET request through the `/api2/v2/*` passthrough**. Reads are
never gated.
---
# Earnings & transactions (/docs/earnings)
## Balances and earnings [#balances-and-earnings]
```bash
# One account, live
curl "$BASE/api/crm/$CRM/accounts/$OFUID/balances" -H "X-API-Key: $KEY"
curl "$BASE/api/crm/$CRM/accounts/$OFUID/earnings" -H "X-API-Key: $KEY"
# Whole panel, aggregated server-side across every connected account
curl "$BASE/api/crm/$CRM/balances/summary" -H "X-API-Key: $KEY"
curl "$BASE/api/crm/$CRM/earnings/summary" -H "X-API-Key: $KEY"
```
The two `/summary` routes are the ones to reach for when building a dashboard β
they aggregate in one request instead of N live calls, and they work across both
platforms. See
[`GET /balances/summary`](/docs/crm/earnings/get-panel-wide-payout-totals) and
[`GET /earnings/summary`](/docs/crm/earnings/aggregated-earnings-across-all-accounts)
for the full field lists.
`/balances/summary` serves last-known samples rather than live values β a live
panel-wide payout total would cost one platform round trip per account. Label it
with the `oldest_sample_at` / `newest_sample_at` it returns, and treat
`accounts_never_sampled` as "not counted yet" rather than zero. Each call to
`GET /accounts/{of_user_id}/balances` refreshes that account's sample.
## Transactions [#transactions]
```bash
# Cached β zero platform requests, trustworthy total
curl "$BASE/api/crm/$CRM/accounts/$OFUID/transactions/cached?limit=100&offset=0" \
-H "X-API-Key: $KEY"
# One fan's history
curl "$BASE/api/crm/$CRM/accounts/$OFUID/fans/$FANID/transactions/cached" \
-H "X-API-Key: $KEY"
# Live walk (cursor-paginated β carry the marker forward)
curl "$BASE/api/crm/$CRM/accounts/$OFUID/purchases?limit=100" -H "X-API-Key: $KEY"
```
Keep the cache current with
`POST /accounts/{of_user_id}/transactions/refresh` (see
[Async jobs](/docs/async-jobs)), and use
`POST /accounts/{of_user_id}/backfill` for history predating the connection.
## The chargeback trap [#the-chargeback-trap]
This is the single most important thing on this page. **Do not compute earnings with a plain sum over transaction amounts.**
A chargeback is the **original row relabelled** β `status` flips to `undo`, `tx_type`
becomes `chargeback`, and `amount`/`net` stay positive. There is no reversing entry.
That means the correct treatment differs by what you are computing:
| Computing | Treatment |
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| **Period revenue** (`/earnings/summary`) | **Exclude** `chargeback` rows. A naive sum overstates by one chargeback; subtracting would double-count the reversal |
| **Per-fan lifetime spend** | **Subtract** them β the platform's canonical lifetime total still contains the original sale, so the delta has to reverse it out |
For per-fan spend, sign each row by status:
| `status` | Meaning | Contribution |
| ------------- | ---------------------------------------------------- | --------------- |
| `done` | Cleared; withdrawable | `+net` |
| `loading` | Within the payout pending window (7 days by default) | `+net` |
| `undo` | Chargeback or refund | `βnet` |
| anything else | Unknown | `0` β ignore it |
```python
def signed_total(rows):
total = 0.0
for row in rows:
status = row.get("status")
# `net` is what the creator received (gross minus the platform fee).
# `amount` is what the fan paid. The canonical lifetime total is net, so
# summing `amount` here inflates the result by roughly 1.25x.
if status in ("done", "loading"):
total += row["net"]
elif status == "undo":
total -= row["net"]
# Unknown statuses contribute nothing. Deliberately not treated as
# positive β if the platform adds a status, you want a visible
# discrepancy rather than a silently wrong number.
return total
```
Treating an unrecognised status as income hides the problem. Contributing zero
makes a new status show up as a divergence you can notice and fix.
Deleted fans still count. The money was real.
## Per-fan lifetime spend [#per-fan-lifetime-spend]
`GET /fans` reports `total_spend` as `MAX(platform lifetime total, sum of captured
tip/purchase events)` β a hybrid that favours whichever source is higher. No signed
delta, no chargeback correction. It also returns `spend_known`: when that is `0`, the
spend is **unknown**, not zero. `total` appears only if you pass `?with_total=true`.
For the chargeback-corrected per-fan figure, read `mapped_spent` from
`GET /accounts/{of_user_id}/fans/{fan_id}/transactions/cached`.
The canonical model below is how per-fan spend is defined:
```
current_spent(fan) = canonical(fan) + signed_delta(fan, since = last subscriber sync)
```
* **canonical** is the platform's own lifetime total for that fan, as of the last
subscriber-cache sync.
* **signed\_delta** is the status-signed sum of that fan's transactions *after*
that sync timestamp.
The cutoff is strictly greater-than, so a transaction landing in the same second
as the sync stays inside `canonical` and is not double-counted.
A fan with transactions but no subscriber row yet gets `canonical = 0` and is
flagged `source: "tx-only"` in the result, so you can tell a genuinely small
spender from one we have not fully synced.
## Money scale differs by platform [#money-scale-differs-by-platform]
OnlyFans reports money as decimal dollars, and the CRM routes pass those through
untouched β `/api2/v2/*` and the CRM money routes agree, so mixing them is safe.
**Fansly reports integer tenths of a cent.** `497496` is `$497.50`. The CRM routes
divide by 1000 on read and multiply by 1000 on write.
Fansly amounts are not cents β they are tenths of a cent. Sending a raw dollar value
to a Fansly write produces a PPV priced 1000x too low. The `/api2/v2/*` passthrough
rejects Fansly accounts outright, so raw Fansly integers are not reachable there.
## Why two caches [#why-two-caches]
Subscriber rows carry the platform's **lifetime** total per fan but are only as
fresh as the last sync. Transactions are fresh and attributable per event but only
cover a recent window, not all history. Combining them gives both lifetime
accuracy and up-to-the-minute freshness β which is exactly what the formula above
does.
If a figure looks wrong, refresh both caches before investigating anything else:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" -H "X-API-Key: $KEY"
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/transactions/refresh" -H "X-API-Key: $KEY"
```
## Payouts [#payouts]
```
GET /accounts/{of_user_id}/payout-account # payout destination
GET /accounts/{of_user_id}/payout-requests # history
POST /accounts/{of_user_id}/payout-requests # request a payout β OnlyFans only
```
Requesting a payout is OnlyFans-only (`501` on Fansly) and sits on the sensitive
rate tier. It **is** behind `allow_of_write_actions`, like every other write performed as the
account. Body: `{"withdrawal_amount": 20}`. The route pre-flights the platform's own
payout eligibility, so a blocked account gets a `400` with a `blockers[]` array
rather than an opaque platform error.
## Referrals are separate money [#referrals-are-separate-money]
`GET /earnings/summary` sums the transaction cache, and referral payouts never land
there β so `by_category.referrals` is reported but is always `0`. Add referral money
explicitly if you need a true total β see
[CRM API β Referrals](/docs/crm/referrals):
```
GET /accounts/{of_user_id}/referrals
GET /accounts/{of_user_id}/referrals/earnings
GET /accounts/{of_user_id}/referrals/payout-requests
```
## Verifying a figure [#verifying-a-figure]
[`GET /accounts/{of_user_id}/earnings/verify`](/docs/crm/earnings/verify-cached-earnings-against-the-platform)
cross-checks the computed total against the platform's own chart for the same
window, and returns both numbers plus the difference. That tells you whether a
discrepancy is in the cache or in your aggregation.
It makes real upstream calls, so it is per-account and on the 100/minute sensitive
tier β do not fan it out across a panel. If the platform is unreachable,
`live_available` is `false` and `live_total` is `null`: that is "could not check",
not "matches".
---
# Events (/docs/events)
Polling is **opt-in per account**. Once you enable it β
`PATCH /accounts/{of_user_id}/polling` with `{"enabled": true}` β we poll that
account in the background, diff the results, and emit typed events. Those events are stored, broadcast over
[SSE](/docs/streaming), delivered to your [webhooks](/docs/webhooks), and
evaluated by your [automations](/docs/automations) β all from the same emission.
## Event types [#event-types]
| `event_type` | Emitted when | Key payload fields |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `new_subscriber` | A new subscription appears | `fan`, `price`, `subscribed_at`, `expire_at` |
| `renewed_subscriber` | An existing subscription renews | `fan`, `price`, `subscribed_at`, `expire_at` |
| `expired_subscriber` | The subscriber count drops (sampled every 10th poll) | `previous_total`, `new_total`, `delta` β **negative** |
| `new_tip` | A `tip` row appears in the transactions ledger | `fan`, `amount`, `net`, `currency`, `tx_type`, `status`, `description` |
| `new_message` | A new-message notification arrives | `fan`, `text` β the only type with a `text` field |
| `new_purchase` | A paid-content row appears β `tx_type` is `message`, `post`, `stream` or `chargeback` | `fan`, `amount`, `net`, `currency`, `tx_type`, `status`, `description` |
| `balance_increased` | **Fansly only** β the wallet balance rises. Never emitted for OnlyFans; the money signal there is `new_tip`/`new_purchase` | `delta`, `available`, `previous_balance`, `new_balance`, `currency` |
| `polling_paused` | Polling auto-pauses after 5 consecutive failures | `reason`, `failures` |
| `payout_completed` | Reserved β **not currently emitted** | β |
Background jobs additionally emit progress events on the SSE stream, which are not
stored or delivered to webhooks:
| Event | Emitted by |
| --------------------------------------- | --------------------------------------------------------------------------------------------- |
| `export.progress` / `export.complete` | [Data exports](/docs/exports) |
| `refresh.progress` / `refresh.complete` | Cache refreshes and backfills, including the automatic backfill when you first enable polling |
| `import.progress` / `import.complete` | [Bulk account import](/docs/bulk-import) β progress is coalesced to at most one per second |
Webhook subscriptions also accept `*` as a wildcard for every type. `*` is a
subscription filter only β it is never the `event_type` of a delivered event.
## Envelope [#envelope]
```json
{
"id": 12345,
"event_type": "new_tip",
"crm_id": "crm_0123456789abcdef",
"of_user_id": "1234567",
"occurred_at": "2026-04-18T12:34:56.789",
"payload": {
"fan": {
"id": "987",
"username": "somefan",
"display_name": "Some Fan",
"avatar": "https://β¦"
},
"amount": 10.0,
"net": 8.0,
"currency": "USD",
"tx_type": "tip",
"status": "done",
"description": "Tip from somefan",
"created_at": "2026-04-18T12:34:50+00:00"
}
}
```
## Reading past events [#reading-past-events]
```bash
curl "$BASE/api/crm/$CRM/events?types=new_tip,new_subscriber&limit=100" \
-H "X-API-Key: $KEY"
```
| Parameter | Purpose |
| ----------------- | ------------------------------------------------------------------------------------------------------------ |
| `types` | Comma-separated event types |
| `of_user_id` | Restrict to one connected account |
| `since` / `until` | ISO bounds on `created_at` (when we recorded it), not `occurred_at`. `since` is exclusive, `until` inclusive |
| `limit` | Default 100, max 500 |
This route is panel-wide and takes no `offset` β narrow with `since`/`until`
rather than paging.
## Behaviours worth knowing [#behaviours-worth-knowing]
`new_subscriber` and `renewed_subscriber` are derived exclusively from the
platform's subscriber feed, distinguished by whether the entry is a subscribe or a
renewal. Notifications are not used for this β they carry no reliable renewal
indicator, so using them would double-count renewals as new subscribers.
`new_tip` and `new_purchase` take `amount` and `net` **directly from the transactions ledger** β real numbers. The exception is `new_message`, whose `amount`
is best-effort-parsed out of a formatted display string (`"$25.00"`) and is often
`null`. Reconcile money against [cached transactions](/docs/earnings) regardless.
This event is derived from a drop in the total subscriber count, sampled only on
every 10th poll β roughly every 20 minutes at the default interval, so several
expiries collapse into one event. `delta` is `new_total - previous_total`, so it is
**negative** (`-3` means three expired), and it carries no fan identity.
To recover *who*, read `GET /subscribers/cached?type=expired`, which names them with
each row's `expired_at`.
Events are unique on `(crm_id, of_user_id, event_type, source_event_id)`.
`source_event_id` is a key we derive per source β the transaction id, the fan id plus
subscribe timestamp, and so on β not always the platform's own id. A poll that
re-walks the same record does not emit twice. Your handler should still be idempotent β webhook *delivery*
retries on failure, so the same event can arrive more than once.
## Choosing a delivery mechanism [#choosing-a-delivery-mechanism]
Best for servers. Signed, retried, with a delivery log. Survives your process
restarting.
Best for dashboards and live UI. No retry β if you disconnect, you miss what
happened while you were gone.
No infrastructure at all. Match a condition, fire a Discord/Slack/Telegram
message, a DM, or a fan tag.
Simplest, and the right fallback for batch reconciliation.
---
# Data exports (/docs/exports)
An async, per-account export. You start a job, it walks the selected data β cheap
cached tables first, expensive live platform walks last β packages **CSV + JSON into a ZIP**, and reports progress live over [SSE](/docs/streaming). The ZIP stays
downloadable for **7 days**.
## Endpoints [#endpoints]
| Method | Path | Purpose |
| -------- | -------------------------------------------------- | -------------------------- |
| `POST` | `/accounts/{of_user_id}/exports` | Start a job β `202` |
| `GET` | `/accounts/{of_user_id}/exports` | History, paginated |
| `GET` | `/accounts/{of_user_id}/exports/{job_id}` | One job's status |
| `GET` | `/accounts/{of_user_id}/exports/{job_id}/download` | Download the ZIP |
| `POST` | `/accounts/{of_user_id}/exports/{job_id}/cancel` | Stop a running job |
| `DELETE` | `/accounts/{of_user_id}/exports/{job_id}` | Delete the job and its ZIP |
## Start an export [#start-an-export]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/exports" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"data_types": ["account","subscribers","transactions","fans","earnings","messages"],
"since": "2026-06-01",
"until": null,
"include_media": false
}'
```
| Field | Type | Notes |
| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data_types` | `string[]` | Non-empty subset of `account`, `subscribers`, `transactions`, `fans`, `earnings`, `messages` |
| `since` | `"YYYY-MM-DD"` \| `null` | Lower bound |
| `until` | `"YYYY-MM-DD"` \| `null` | Upper bound. Both null means all-time |
| `include_media` | `boolean` | Download message media. **Required:** `messages` must be in `data_types`, or this is silently ignored. OnlyFans only β a Fansly export records a warning and lists attachment URLs in the message JSON instead |
The `202` response includes a warning worth heeding:
```json
{
"success": true,
"job": { "job_id": "f01152d3β¦", "status": "queued", β¦ },
"warning": "Messages and media are fetched live from the platform β this can take several minutes and counts against your API quota."
}
```
`account`, `subscribers`, `fans` and (on OnlyFans) `transactions` are cached reads
and nearly free. **`earnings` costs 1β2 live calls.** Fansly `transactions` falls
back to a live wallet walk of up to 20 pages when its cache is cold. `messages` β
and especially `include_media` β is the expensive one. Export messages when you need
them, not by default.
## One export at a time [#one-export-at-a-time]
A second request while one is in flight returns the **existing** job rather than an
error:
```json
{ "already_running": true, "job": { β¦ } }
```
Unless that job is stale β no terminal status for over **30 minutes** β in which
case it is marked failed and yours starts fresh.
## Track progress [#track-progress]
Poll:
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/exports/$JOB_ID" -H "X-API-Key: $KEY"
```
Or stream, which is what the dashboard does:
```bash
curl -N "$BASE/api/crm/$CRM/events/stream" -H "X-API-Key: $KEY"
```
```
event: export.progress
data: {"job_id":"β¦","status":"running","phase":"messages","phase_index":6,
"phase_total":7,"counts":{β¦},"updated_at":"β¦"}
event: export.complete
data: {"job_id":"β¦","status":"complete",β¦}
```
`phase_index` / `phase_total` give you a real progress bar; `phase` names what is
running. Because messages come last, a job can sit at high progress for a long
time β that is the live walk, not a stall.
## Download [#download]
```bash
curl -L -o export.zip \
"$BASE/api/crm/$CRM/accounts/$OFUID/exports/$JOB_ID/download" \
-H "X-API-Key: $KEY"
```
Responds `application/zip`. What each type produces differs:
| Type | Files |
| ------------------------------------- | --------------------------------------------------------------- |
| `subscribers`, `transactions`, `fans` | `.json` **and** `.csv` |
| `account`, `earnings` | `.json` only |
| `messages` | one JSON per conversation, plus `messages.csv` and `index.json` |
Available for 7 days, then cleaned up. `DELETE` the job to remove it sooner.
Statuses are `queued`, `running`, `complete`, `failed`, `canceled`, `expired` β the
terminal success value is `complete`, not `completed`. A poll loop waiting for
`"completed"` never fires.
## Cancelling [#cancelling]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/exports/$JOB_ID/cancel" \
-H "X-API-Key: $KEY"
```
Worth doing if you started a media export by accident β it will otherwise keep
consuming quota until it finishes.
## There is a UI for this [#there-is-a-ui-for-this]
`Dashboard β Export` drives exactly these endpoints. If a human needs a one-off
download rather than an integration, send them there.
---
# FAQ (/docs/faq)
The risk is real and it comes almost entirely from **request pacing**, not from the
API itself. We keep sessions stable, sign requests with the current algorithm, and
route each account through the proxy you supplied β the things that get accounts
flagged when done wrong. (If you supply no proxy, traffic egresses on our server IP;
see below.)
What is left to you: pace your live calls. As a reference point, our own background
poller runs at roughly 3β5 requests per *two minutes* per account, and internal
sweeps self-throttle to about 1 req/s β that is the shape of traffic the platforms
are comfortable with.
The [cached routes](/docs/cached-reads) cost zero platform requests, so read from
those and refresh them on a schedule rather than polling live endpoints in a loop.
See [Rate limits & quotas](/docs/rate-limits).
For OnlyFans, in practice yes β but it is **not enforced**. Omit it and the session
is created from our server's IP and pinned there, which is what gets accounts flagged
later. A malformed `X-Proxy` is silently ignored rather than rejected, so a typo
degrades to direct egress without any error.
Use one sticky residential or ISP proxy per account, and keep the exit location
stable. Rotating proxies defeat the purpose. See [Proxies](/docs/proxies).
Only requests that actually reach the platform: live reads, refreshes, backfills,
payout calls and every `/api2/v2/*` call β about 30 of \~145 routes. **Cached reads, async-job status polls and background polling are free**, in quota as well as in
platform requests.
`GET /health` and `GET /events/stream` are also exempt from the per-minute limit.
Check consumption at `GET /api/crm/{crm_id}/usage`; `api_calls_limit: -1` means
unlimited.
The normalized CRM routes are Fansly-aware and return the same shapes for both
platforms. Not supported for Fansly: the `/api2/v2/*` passthrough (OnlyFans-only by
definition), payout requests, campaign claimers, mass DM, campaign *creation*,
subscription-price *updates*, PPV stats, the three referrals routes, and real-time
WebSocket (Fansly is polling-only). Most return `501` with
`code: "platform_not_supported"` plus `platform` and `feature`.
Full matrix in [OnlyFans & Fansly](/docs/platforms).
You are not talking to the API. `_notice` comes from our honeypot layer on **decoy paths** β most commonly `https://theonlyapi.com/api/health`, which is bait and
returns fabricated data. The selection is by path, not by whether you authenticated.
Real API paths are panel-scoped: `https://theonlyapi.com/api/crm/{crm_id}/β¦`. A
missing or wrong key there returns a plain `401`/`403` JSON error with no `_notice`.
Details in [Responses & errors](/docs/responses).
It does not. There is no User-Agent check anywhere on the API β a `curl` request and
a browser-UA request get byte-identical responses, and `-A "Mozilla/5.0"` changes
nothing. If you are seeing decoy data, you are on a decoy **path** (see the previous
answer).
OnlyFans write operations are gated per account, off by default:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"allow_of_write_actions": true}'
```
The gate now covers **every OnlyFans or Fansly write performed as the account**:
single DM/PPV sends (both platforms), the mass-DM real send, the `send_dm`
automation action, payout requests, subscription-price changes, the raw
`POST /accounts/{of_user_id}/request` proxy, and **any non-GET request through the `/api2/v2/*` passthrough**. Reads are never gated.
Match on `code: "WRITES_DISABLED"` rather than the message text.
Almost certainly the pagination quirks. `/chats` ignores `limit` and returns 10β14
conversations regardless, and a short page from `/messages` does **not** mean the end
of the conversation.
Note the CRM `/messages` route is **offset**-paged; the `id=` cursor works only on the
`/api2/v2/chats/{fan_id}/messages` passthrough, which is the reliable way to walk a
full history. See [Pagination](/docs/pagination).
Two likely causes. You are summing `amount` (the gross the fan paid) instead of
`net` (what the creator received) β that inflates the total by roughly 1.25x. And you
are counting chargebacks: a reversal is the **original row relabelled** to
`status: "undo"` / `tx_type: "chargeback"` with the amount still positive.
For period revenue, **exclude** chargeback rows β a naive sum overstates by one
chargeback, and subtracting double-counts the reversal. For per-fan lifetime spend,
subtract them. See [the chargeback trap](/docs/earnings).
First check `status` β a webhook on an unapproved domain is `pending` and delivers
nothing at all. Otherwise: five consecutive failed deliveries (each an exhausted
retry ladder) deactivates it until you re-enable it with `PATCH /webhooks/{id}` and
`{"is_active": true}`. Check
`GET /webhooks/{id}/deliveries` for the failures, fix the endpoint, re-enable, then
backfill the gap from `GET /events?since=β¦`.
See [Webhooks](/docs/webhooks).
The session probably died. After five consecutive failures, polling for that
account is paused and a `polling_paused` event is emitted. Reconnecting clears the
session block but **not** the pause β re-enable polling explicitly with
`PATCH /accounts/{of_user_id}/polling` and `{"enabled": true}`.
Set up a `polling_paused` [automation](/docs/automations) so you hear about this
before your users do. See [Sessions & re-login](/docs/sessions).
Not with your API key. A key grants full access to the panel, so it must never reach
client-side JavaScript β a browser cannot keep a secret.
Cross-origin requests to `/api/*` are restricted to an allowlist, so a page on
another domain cannot read your panel even if it has a key.
Proxy through your own backend and attach the key server-side. For live events this
is required regardless, because the browser `EventSource` API cannot set headers β
there is a worked example in [Server-sent events](/docs/streaming).
Not yet. The API is plain REST with a single header, and there is an
[OpenAPI 3.1 spec](https://theonlyapi.com/api/openapi.json) you can point a
generator at.
For AI-assisted work, the [MCP server](/docs/ai-agents) is usually faster than
generating a client.
Three differences to plan for:
1. **Panel-scoped paths.** Every route carries your `crm_id`:
`https://theonlyapi.com/api/crm/{crm_id}/β¦`. Seven unscoped routes are the
exception, and they live on `api.theonlyapi.com` β see
[Base URL & panels](/docs/base-url).
2. **Two surfaces.** Providers that only proxy OnlyFans map onto our
`/api2/v2/*` passthrough. The normalized CRM layer β cached reads,
aggregations, events, exports β has no equivalent and is where the value is.
See [The two surfaces](/docs/two-surfaces).
3. **Slot pricing, not per-call metering.** Paid plans have unlimited API calls;
you pay per connected account. So caching is about protecting the *platform's*
limits, not your bill.
Sessions do not transfer. Reconnect each account with
[session paste](/docs/connect-an-account) β it takes about a minute per account.
[Contact us](https://theonlyapi.com/contact). For a `500`, include the
`correlation_id` from the response body β it maps to the full server-side
traceback, which is deliberately not returned to callers.
---
# Get an API key (/docs/get-an-api-key)
Every request needs an `X-API-Key` header. Keys are scoped to a single CRM
panel.
## Get your first key [#get-your-first-key]
Sign up on the website. Your key already exists by the time you land in the
dashboard.
Registration happens through the website form. Creating the account also
creates your CRM panel and mints its first API key, in the same step.
Your `crm_id` and key are here, alongside per-key usage and the controls to
mint or revoke additional keys.
There is no separate "create a panel" step and no API call to make. A panel is
the tenant your key belongs to, and one is provisioned for you at signup β
`crm_id`, primary key, and the user record linking them are all written together.
The rest of this page assumes you are signed in and looking at
**Dashboard β API Keys**.
## Primary and secondary keys [#primary-and-secondary-keys]
A panel has exactly one **primary** key (named `Default`) plus any number of
**secondary** keys.
| | Primary | Secondary |
| ---------------------------------------------------- | ---------------------- | --------- |
| Read and write panel data | yes | yes |
| Create a secondary key (`POST /api-keys`) | yes | **403** |
| Revoke a secondary key (`DELETE /api-keys/{key_id}`) | yes | **403** |
| Can be revoked | no β rotate it instead | yes |
Use secondary keys to give each service, environment or teammate its own
credential, so you can revoke one without disrupting everything else. Per-key
usage is tracked separately.
`POST /api-keys` returns the full key in that response and never again. List
endpoints only ever return a `prefix`. If you lose a secondary key, revoke it
and mint a new one.
```bash
curl -X POST "$BASE/api/crm/$CRM/api-keys" \
-H "X-API-Key: $PRIMARY_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "production-worker"}'
```
## Rotate a key [#rotate-a-key]
Rotation replaces the key you are calling with. The old key dies **immediately**
and the new one is returned once in the response body.
```bash
curl -X POST "$BASE/api/crm/$CRM/rotate-key" \
-H "X-API-Key: $KEY"
```
The primary `Default` key cannot be revoked β rotate it.
## Resolve a key to a panel [#resolve-a-key-to-a-panel]
If you have a key but not its `crm_id`:
```bash
curl "https://api.theonlyapi.com/api/whoami" \
-H "X-API-Key: $KEY"
```
This is also how the hosted MCP server turns a bearer token into a tenant.
## Check your usage [#check-your-usage]
```bash
curl "$BASE/api/crm/$CRM/usage" -H "X-API-Key: $KEY"
```
Returns `plan`, `api_calls_used`, `api_calls_limit`, `accounts_used` and
`accounts_limit`. An `api_calls_limit` of `-1` means unlimited. Per-key detail β
30/90-day series, month and all-time totals, endpoint breakdown β is at
`GET /api-keys/{key_id}/usage`.
## Keeping keys safe [#keeping-keys-safe]
* Send keys in the `X-API-Key` **header** only. The API deliberately does not
read a key from a query parameter, so keys never land in access logs.
* Some keys in circulation are **canary tokens**. Using a key you found rather
than one you were issued raises an alert while still failing as
`Invalid API key`.
[Authentication](/docs/authentication) covers what each failure code means.
---
# Integrations (/docs/integrations)
Panel-scoped and authenticated exactly like every other CRM route. Each endpoint
below links to its full reference page.
## Telegram notifications [#telegram-notifications]
A first-class Telegram integration, separate from the `telegram` action available
in [automations](/docs/automations). Pairing is done through a deep link, so a bot
token never has to be sent to a chat.
| Endpoint | What it does |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| [`GET /integrations/telegram`](/docs/crm/integrations/get-the-telegram-integration) | Current integration + whether a shared bot is available |
| [`POST /integrations/telegram/pair`](/docs/crm/integrations/start-telegram-pairing) | Start or restart pairing β returns a `t.me` deep link |
| [`PATCH /integrations/telegram`](/docs/crm/integrations/update-the-telegram-integration) | Update event types or pause delivery (404 if none configured) |
| [`POST /integrations/telegram/test`](/docs/crm/integrations/send-a-telegram-test-message) | Send a test notification |
| [`DELETE /integrations/telegram`](/docs/crm/integrations/remove-the-telegram-integration) | Remove the integration |
| [`POST /integrations/telegram/groups`](/docs/crm/integrations/register-telegram-groups) | **List** the groups your own bot can see |
### Pairing [#pairing]
```bash
curl -X POST "$BASE/api/crm/$CRM/integrations/telegram/pair" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"bot_mode": "shared",
"event_types": ["new_tip", "new_subscriber"]
}'
```
| Field | Notes |
| ------------- | -------------------------------------------------------------------- |
| `bot_mode` | `"shared"` to use the platform's own bot, or `"custom"` for your own |
| `bot_token` | Required for `"custom"` only |
| `event_types` | Optional filter; omit for everything |
The response is a `t.me` deep link β **never a token**. Open it and start the bot
to complete pairing.
`GET /integrations/telegram` reports `shared_bot_available`, so a UI can hide the
shared-bot option on a deployment where no shared bot is configured rather than
offering a button that fails.
### Read, test, remove [#read-test-remove]
```bash
curl "$BASE/api/crm/$CRM/integrations/telegram" -H "X-API-Key: $KEY"
curl -X POST "$BASE/api/crm/$CRM/integrations/telegram/test" -H "X-API-Key: $KEY"
curl -X DELETE "$BASE/api/crm/$CRM/integrations/telegram" -H "X-API-Key: $KEY"
```
`PATCH` returns `404 No Telegram integration configured` if you have not paired
yet β pair first, then update.
`POST /integrations/telegram/groups` **lists** the group chats your bot can see so a
UI can offer a picker β it registers nothing. It requires `{"bot_token": "β¦"}` and is
custom-bot only; passing the shared platform token is refused with `400`, because
that token is a single-consumer stream.
Writes here are on the 100/minute sensitive-route limit; the `GET` is on the
default 100/minute.
## Account tags [#account-tags]
Tag connected accounts (distinct from [fan tags](/docs/subscribers)) β useful for
grouping creators by manager, niche or status.
| Endpoint | What it does |
| -------------------------------------------------------------------------------------- | ----------------------------------------- |
| [`POST /accounts/{of_user_id}/tags`](/docs/crm/accounts/tag-a-connected-account) | Add a tag β body `{"tag": "vip-creator"}` |
| [`DELETE /accounts/{of_user_id}/tags/{tag}`](/docs/crm/accounts/remove-an-account-tag) | Remove a tag |
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/tags" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"tag": "managed-by-alex"}'
```
Tags are capped at 40 characters. The full set for the panel comes back as
`all_tags` on `GET /accounts`.
Tag values are HTML-escaped once when stored. The `DELETE` path segment is
deliberately **not** re-escaped, so pass the tag exactly as it was stored β a
double-escaped value silently matches nothing and deletes nothing.
## Referrals [#referrals]
Full reference: [CRM API β Referrals](/docs/crm/referrals).
Referral money is separate from subscription and tip revenue, so it is not
included in `GET /earnings/summary` β add it explicitly if your reporting needs a
true total. See [Earnings & transactions](/docs/earnings).
## Earnings verification [#earnings-verification]
```
GET /accounts/{of_user_id}/earnings/verify
```
Cross-checks the computed earnings figures against the platform's own totals.
Useful when a number looks wrong and you want to know whether the discrepancy is
in the cache or in your own aggregation β see
[the chargeback trap](/docs/earnings) for the most common cause.
## Request metrics [#request-metrics]
[`GET /metrics/requests`](/docs/crm/panel-and-usage/get-request-metrics-for-your-panel)
returns your panel's own request outcomes over a window β volume, status classes,
error rate, latency, and the slowest and most error-prone routes. It is what the
dashboard Overview charts, and it includes the current partial bucket so the
numbers are not up to a minute stale.
## Related guides [#related-guides]
| Endpoint | Guide |
| ------------------------------------------------------------------------------------------------------------ | ----------------------------------------- |
| The eight [`/import/*`](/docs/crm/bulk-import) routes | [Bulk account import](/docs/bulk-import) |
| [`GET /balances/summary`](/docs/crm/earnings/get-panel-wide-payout-totals) | [Earnings & transactions](/docs/earnings) |
| [`GET /accounts/{of_user_id}/fansly-credentials`](/docs/crm/accounts/read-a-fansly-account-s-stored-session) | [OnlyFans & Fansly](/docs/platforms) |
| [`POST /accounts/{of_user_id}/refresh/{kind}/clear`](/docs/crm/cache-and-sync/clear-a-stuck-refresh-job) | [Async jobs](/docs/async-jobs) |
Discord, Slack and OnlyFans DM notifications are delivered through
[automation actions](/docs/automations) β you configure them as part of a rule
rather than as a standalone integration. Only Telegram has dedicated endpoints,
because of the pairing handshake.
---
# Messaging & mass DM (/docs/messaging)
## Read a conversation [#read-a-conversation]
```bash
# List conversations
curl "$BASE/api/crm/$CRM/accounts/$OFUID/chats" -H "X-API-Key: $KEY"
# Message history with one fan
curl "$BASE/api/crm/$CRM/accounts/$OFUID/chats/$FANID/messages" -H "X-API-Key: $KEY"
```
`/chats` largely ignores `limit` and returns 10β14 conversations regardless, and
`/messages` uses an `id=` cursor where a short page does **not** mean the end. A
walker that gets this wrong loses most of a message history. Read
[Pagination](/docs/pagination) before writing one.
## Send a DM or PPV [#send-a-dm-or-ppv]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/chats/$FANID/messages" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "Hey! New drop is up π",
"price": 0,
"mediaFiles": []
}'
```
* `price > 0` makes the message a **PPV**.
* `mediaFiles` takes vault media IDs, locked behind the price.
* Works for OnlyFans and Fansly.
Rate limited to 100 requests/minute.
OnlyFans sends are gated per account by `allow_of_write_actions`. Without it you
get a `403` with `code: "WRITES_DISABLED"` (match on the code, not the message).
This applies to Fansly sends too:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"allow_of_write_actions": true}'
```
## Mass DM [#mass-dm]
**OnlyFans only** β Fansly returns `501` with
`{"code": "PLATFORM_NOT_SUPPORTED"}`.
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/messages/mass" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "New set just dropped π₯",
"price": 12,
"mediaFiles": ["1234567"],
"previews": ["1234567"],
"audience": {
"min_spent": 20,
"since": "2026-01-01T00:00:00Z"
},
"dry_run": true
}'
```
A message needs `text`, `mediaFiles`, or both β an empty send is rejected with
`400 message requires text or mediaFiles`.
### `dry_run` defaults to true [#dry_run-defaults-to-true]
This is the important detail. `dry_run` is **`true` unless you explicitly set it to `false`**. In dry-run mode the API resolves the audience, counts the recipients and
returns a sample β **without sending anything**.
```json
{
"success": true,
"dry_run": true,
"recipients": 418,
"sent": 0,
"sample": [ { "fan_of_user_id": "987", "username": "somefan" } ],
"note": "Preview only β no messages were sent."
}
```
`recipients` is the resolved count; `sample` is the first **10** only.
This is the "who will this actually reach?" preview, and it costs nothing. Check
the count and the sample, then re-send the identical body with
`"dry_run": false`.
A mass DM cannot be recalled once sent.
### Audience filters [#audience-filters]
| Field | Effect |
| ----------------- | --------------------------------------------------------------------------- |
| `type` | `all` \| `active` \| `expired` β **defaults to** `active` |
| `fan_ids` | Explicit id list. **Overrides every other filter.** Truncated at 5,000 |
| `min_spent` | Only fans who have spent at least this much |
| `since` / `until` | Inclusive bounds on the fan's `subscribed_at` |
Omitting `type` reaches only **current** subscribers, not everyone.
There is no `tag` field β the audience is resolved from the subscriber cache, which
carries no tags, so a `tag` key is silently ignored. To target a segment, resolve it
yourself with `GET /fans`, then pass the ids as `audience.fan_ids`.
## PPV performance [#ppv-performance]
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/ppv-stats" -H "X-API-Key: $KEY"
```
Unlock rates and revenue per PPV message, so you can tell which sends actually
earned.
## Vault media [#vault-media]
Media IDs for `mediaFiles` and `previews` come from the vault, which lives on the
[passthrough surface](/docs/two-surfaces) β see the **Content** tag in the
[OnlyFans passthrough reference](/docs/of-api). Upload and vault management are not
exposed on the CRM layer.
`previews` should be a subset of `mediaFiles`: those items stay unlocked as a teaser
while the rest sit behind the price.
## Pacing [#pacing]
Sends are OnlyFans writes and hit the platform directly. See
[Rate limits](/docs/rate-limits).
The real send is **not** dispatched to a background worker and is **not** paced. It
loops through recipients synchronously inside the HTTP request β up to 5,000 sends
on one connection. Keep audiences small enough that the request completes, and pace
by splitting the audience with `since`/`until` rather than relying on server-side
throttling.
---
# Pagination (/docs/pagination)
Three pagination conventions coexist in this API, inherited from the two
platforms underneath. Getting these wrong silently loses data β usually most of
it β so this page is worth reading in full before you write a walker.
## 1. Offset + limit, with a total [#1-offset--limit-with-a-total]
The `/cached` routes and other server-side reads.
```
GET /accounts/{of_user_id}/subscribers/cached?limit=100&offset=0
```
```json
{ "success": true, "list": [ β¦ ], "count": 100, "total": 412,
"limit": 100, "offset": 0, "hasMore": true }
```
Walk until `hasMore` is `false`. This is the only convention where `total` is
trustworthy.
The high-traffic paginated reads return their rows under a generic `list`, not
under a key named after the resource. Getting this wrong yields zero rows silently.
| Route | Array key |
| ----------------------------------------------------------------------- | ------------------------ |
| `/subscribers/cached`, `/subscribers` (live) | `list` |
| `/transactions/cached`, `/fans/{id}/transactions/cached` | `list` |
| `/claimers/cached` | `list` |
| `/subscribers/new` | `subscribers` |
| `/fans`, `/campaigns`, `/claimers`, `/purchases`, `/chats`, `/messages` | named after the resource |
Per-route limits: `/subscribers/cached` and `/fans` max 500; `/transactions/cached`,
`/fans/{id}/transactions/cached` and `/claimers/cached` max 1000;
`/subscribers/new` defaults to **50**; `/notifications` defaults to 20, max 100;
`/events` defaults to 100, max 500. Exceeding a max is a `400`, not a clamp.
## 2. Offset + limit, no total [#2-offset--limit-no-total]
Live reads that proxy a platform which does not report a total β `/campaigns`,
`/claimers`, `/purchases`, live `/subscribers`.
```json
{ "success": true, "campaigns": [ β¦ ], "hasMore": true }
```
There is no `total` β and these routes do not echo `limit` or `offset` back either.
Walk on `hasMore` alone, tracking the offset yourself.
`GET /accounts/{of_user_id}/subscribers` returns `nextOffset`. Always pass that
back. Do **not** compute `offset + len(list)` there: on Fansly we request an
unfiltered page and filter it server-side, so the stream consumed more rows than you
received β advancing by `len(list)` silently skips every filtered-out subscriber.
Only on routes that emit no cursor (`/chats`, `/campaigns`, `/claimers`) do you
advance yourself, and there you must use the page length, because a short page does
not mean the end:
```python
offset += len(page) # NOT offset += limit
```
## 3. Cursor (`marker` / `id`) [#3-cursor-marker--id]
The OnlyFans transactions walker uses an opaque `marker`:
```
GET /accounts/{of_user_id}/purchases?limit=100&marker=
```
```json
{ "success": true, "purchases": [ β¦ ], "marker": "eyJβ¦", "hasMore": true }
```
The response carries both `marker` (the one you sent) and `nextMarker` β pass
`nextMarker` back on the next request. Do not construct or parse it.
Message history uses an `id` cursor β but **only on the passthrough**:
```
GET /api/crm/{crm_id}/api2/v2/chats/{fan_id}/messages?id=
```
`GET /accounts/{of_user_id}/chats/{with_user_id}/messages` accepts only `limit`
(max 100, default 50) and `offset`; an `id` parameter is silently dropped. Offset
paging over this platform endpoint is lossy β it both skips and repeats messages.
Use the CRM route to read a recent window. To walk a **full** history, go through
the passthrough with the `id=` cursor, dedupe on message id, and stop when a page
yields nothing new.
## The two traps [#the-two-traps]
These are the specific behaviours that break naive walkers.
`GET /accounts/{of_user_id}/chats` accepts a `limit` parameter and then largely
disregards it β a request for 50 typically returns **10 to 14** conversations.
Do not treat "fewer than `limit` returned" as the end of the list, and do not
size your loop by dividing a total by `limit`. Walk on `hasMore`, advancing
`offset` by the number of items you received.
`GET /accounts/{of_user_id}/chats/{with_user_id}/messages` needs the `id=`
cursor, and it will regularly return a short page β sometimes a nearly empty one
β in the middle of a conversation that has thousands more messages.
Terminate on the platform's own `hasMore` signal, or on receiving a genuinely
empty page, and never on `len(page) < limit`. A walker that stops at the first
short page can lose the overwhelming majority of a message history.
## `hasMore` is camelCase [#hasmore-is-camelcase]
Note the casing. Bodies are otherwise snake\_case (`of_user_id`, `created_at`,
`api_calls_used`), but `hasMore` is camelCase because it comes straight from
OnlyFans. `total`, `limit` and `offset` are lowercase.
## A correct walker [#a-correct-walker]
```python
import requests
def walk(session, url, key, params=None, item_key="items", page_size=100):
"""Walk an offset-paginated CRM route without losing short pages."""
offset = 0
while True:
r = session.get(
url,
headers={"X-API-Key": key},
params={**(params or {}), "limit": page_size, "offset": offset},
timeout=60,
)
r.raise_for_status()
body = r.json()
page = body.get(item_key) or []
yield from page
# Advance by what we actually received β these offsets are per item.
offset += len(page)
# Trust the platform's own signal, not the page length.
if not body.get("hasMore"):
return
if not page:
return # defensive: hasMore true but nothing coming back
```
For cursor routes, carry the `marker` (or `id`) forward instead of an offset, and
stop when `hasMore` is false.
## Routes that take only `limit` [#routes-that-take-only-limit]
`GET /events` (default 100, max 500) and
`GET /accounts/{of_user_id}/notifications` (default **20**, max **100**) accept
`limit` with no offset. `/events` is filtered by `types`,
`of_user_id`, `since` and `until` instead β see [Events](/docs/events).
---
# OnlyFans & Fansly (/docs/platforms)
Every connected account has a `platform` of either `onlyfans` or `fansly`. All
three connection routes accept a `platform` field in the request body
(`"onlyfans" | "fansly"`, default `"onlyfans"`).
Once connected, the account ID is returned as `of_user_id` **regardless of platform**. That same value is the `{of_user_id}` path parameter and the
`user-id` header on the `/api2/v2/*` passthrough β the only route that reads it.
## Support matrix [#support-matrix]
| Surface | OnlyFans | Fansly |
| ------------------------------------------------------------------------------------------------- | -------- | ------------------------------- |
| Most CRM data routes (`/accounts/β¦`, `/fans`, `/events`, `/webhooks`, `/automations`, `/exports`) | yes | yes |
| Transparent passthrough (`/api2/v2/*`) | yes | **no β 501** |
| `POST /accounts/{of_user_id}/payout-requests` | yes | **no β 501** |
| `GET /accounts/{of_user_id}/campaigns/{campaign_id}/claimers` | yes | **no β 501** |
| `POST /accounts/{of_user_id}/campaigns` (create) | yes | **no β 501** |
| `PATCH /accounts/{of_user_id}/subscription-price` | yes | **no β 501** (GET works) |
| `GET /accounts/{of_user_id}/ppv-stats` | yes | **no β 501** |
| The three `/referrals` routes | yes | **no β 501** |
| `POST /accounts/{of_user_id}/request` (raw proxy) | yes | **no β 501** |
| Real-time WebSocket | yes | **no** β Fansly is polling-only |
| `POST /accounts/{of_user_id}/messages/mass` (mass DM) | yes | **no β 501** |
## What rejection looks like [#what-rejection-looks-like]
Nearly every rejection is a `501` from one shared helper, with a lowercase code plus
the feature name:
```json
{ "success": false, "code": "platform_not_supported",
"error": "\"referrals\" is not available for fansly accounts yet.",
"platform": "fansly", "feature": "referrals" }
```
Mass messaging is the one exception β it hand-rolls an **uppercase** code, so compare
case-insensitively:
```json
{
"success": false,
"code": "PLATFORM_NOT_SUPPORTED",
"error": "mass messaging is OnlyFans-only for now"
}
```
## Write platform-agnostic code [#write-platform-agnostic-code]
Prefer the **normalized CRM routes**. They are Fansly-aware, they project both
platforms' rows to the same shape, and several of them are server-side
aggregations with no platform equivalent at all:
```
/accounts/{of_user_id}/notifications
/accounts/{of_user_id}/balances
/accounts/{of_user_id}/chats
/accounts/{of_user_id}/purchases
/accounts/{of_user_id}/subscribers/cached
/accounts/{of_user_id}/transactions/cached
/earnings/summary
/fans
```
Reach for the passthrough only when you need an OnlyFans capability the CRM layer
does not expose β and branch before you do. `GET /accounts` returns a per-account
`capabilities` object, which is the authoritative matrix; prefer it over
hard-coding this table.
do.
Fansly reports monetary amounts at a different scale than OnlyFans. The
normalized CRM routes handle the conversion for you; raw passthrough responses
do not, because they are OnlyFans' own bytes. See
[Earnings & transactions](/docs/earnings).
## Fansly-specific helpers [#fansly-specific-helpers]
```
GET /accounts/{of_user_id}/fansly-credentials
```
Returns the stored Fansly credential block for a connected account.
---
# Posting & scheduling (/docs/posting)
Posting media is two calls: upload the file, then create the post referencing
what you got back.
```bash
# 1. upload
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media" \
-H "X-API-Key: $KEY" -F "file=@photo.jpg"
# -> {"success":true,"media":{"processId":"β¦","host":"β¦","thumbId":1,"name":"photo.jpg","extra":"β¦"}}
# 2. post it, passing that object through whole
curl -X POST "$BASE/api/crm/$CRM/api2/v2/posts" \
-H "X-API-Key: $KEY" -H "user-id: $OFUID" -H "Content-Type: application/json" \
-d '{"text":"new set π₯","mediaFiles":[{"processId":"β¦","host":"β¦","thumbId":1,"name":"photo.jpg","extra":"β¦"}]}'
```
A runnable Python client covering upload, immediate posting, scheduling and
every trap on this page:
[theonlyapi-posting-example.zip](/theonlyapi-posting-example.zip).
## Uploading [#uploading]
Two ways in β raw bytes, or a URL we fetch for you.
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media" \
-H "X-API-Key: $KEY" \
-F "file=@photo.jpg"
```
Any format OnlyFans accepts, including **HEIC** straight off an iPhone. Up to
512 MB.
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"source_url":"https://drive.google.com/uc?export=download&id=FILE_ID"}'
```
The URL must return the **file bytes**, not a viewer page. For Google Drive
that means sharing as "Anyone with the link" and using the
`uc?export=download&id=` form β a `/file/d/β¦/view` link returns HTML and is
rejected at `stage: "fetch"`. Large Drive files return a virus-scan
interstitial and can't be fetched this way.
OnlyFans has no single upload endpoint of its own β internally it's a signed S3
create, a direct `PUT` of the bytes (5 MiB parts above 5 MiB), a multipart
finish, then a handoff to a converter host. `POST /accounts/{of_user_id}/media`
runs all four server-side so you make one call.
On failure the response carries a `stage` β `fetch`, `create`, `put` or
`convert` β telling you which part broke.
## Scheduling [#scheduling]
Send `isScheduled` **and** `scheduledDate`:
```bash
curl -X POST "$BASE/api/crm/$CRM/api2/v2/posts" \
-H "X-API-Key: $KEY" -H "user-id: $OFUID" -H "Content-Type: application/json" \
-d '{
"text": "going live tomorrow",
"mediaFiles": [ { "processId": "β¦", "host": "β¦", "thumbId": 1, "name": "photo.jpg", "extra": "β¦" } ],
"isScheduled": 1,
"scheduledDate": "2026-08-20T12:00:00+00:00"
}'
```
Confirm it actually queued β `GET /api2/v2/schedules` should list the post id:
```bash
curl "$BASE/api/crm/$CRM/api2/v2/schedules" -H "X-API-Key: $KEY" -H "user-id: $OFUID"
```
Delete a queued post the same way as a published one:
`DELETE /api2/v2/posts/{post_id}`.
## Four ways this fails silently [#four-ways-this-fails-silently]
Each of these returns **HTTP 200** while doing nothing you wanted.
`postedAt` is ignored on create. In **either** format (`β¦Z` or `β¦+00:00`) the
post publishes immediately. If you have been scheduling with `postedAt`, those
posts went out the moment you created them.
Use `isScheduled` + `scheduledDate`, and verify against `/api2/v2/schedules`.
| body | result |
| ------------------------------------------------------- | ------------------------- |
| `mediaFiles: [{processId, host, thumbId, name, extra}]` | attached |
| `media: [{β¦same objectβ¦}]` | post created, **0 media** |
| `mediaFiles: [{processId}]` alone | post created, **0 media** |
Pass the upload result through **whole**. Dropping `thumbId` or `name` loses
the attachment with no error. Check `media.length` on the response.
OnlyFans creates the vault row when a post **consumes** the media β roughly
10β15 seconds after posting, not at upload time. A fresh upload will not appear
in `GET /api2/v2/vault/media`, nor in `/vault/media/processing`, nor under
`/vault/media/hash`.
This is OnlyFans' own behaviour: it has no upload-to-vault operation, and its
web client's vault page is browse/organise only. Don't poll the vault for a new
upload's id β use the returned object. Integer vault IDs are only for re-using
media that has already been posted.
Two posts inside ten seconds returns `400 "Please allow 10 seconds"`. Space
them by 12s when publishing in a loop.
## Write actions are off by default [#write-actions-are-off-by-default]
Uploading and posting act as the creator, so both are gated per account. Enable
once:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"allow_of_write_actions": true}'
```
Without it you get `403` with `code: "WRITES_DISABLED"` β match on the code, not
the message. Check current state with `GET /accounts/{of_user_id}/polling`.
The account capability `send_attachments` refers to **DM** attachments, which
are not wired yet. Upload support is reported by `media_upload`.
## Re-using media already in the vault [#re-using-media-already-in-the-vault]
Media that has been posted before is referenced by plain integer id:
```bash
curl "$BASE/api/crm/$CRM/api2/v2/vault/media?field=recent&sort=desc&limit=24" \
-H "X-API-Key: $KEY" -H "user-id: $OFUID"
```
Both forms can go in the same `mediaFiles` array β integers for existing vault
media, whole objects for fresh uploads.
To match a local file against something already uploaded, use
`GET /api2/v2/vault/media/hash?h={md5}&size={bytes}` (note `h`, not `hash`). The
MD5 must be of the **original** bytes; a re-encoded or CDN-downloaded copy will
not match.
## Stories and messages [#stories-and-messages]
The same uploaded object attaches to stories (`POST /api2/v2/stories`) and DMs
(`POST /api2/v2/chats/{fan_id}/messages`) through the same `mediaFiles` field.
See [Messaging & mass DM](/docs/messaging) for the send flow and the PPV
pricing rules.
## Platform support [#platform-support]
OnlyFans only. Fansly accounts return `501` from the upload endpoint β its
upload pipeline is not wired yet. See [Platforms](/docs/platforms).
---
# Proxies (/docs/proxies)
OnlyFans ties a session to the network it was created from. If requests for that
session start arriving from a different IP, the session gets invalidated. So every
OnlyFans account is bound to a proxy at connect time, and we route all of its
subsequent traffic through the same one.
| Platform | `X-Proxy` on connect | On later requests |
| -------- | -------------------------------------- | ------------------------------------ |
| OnlyFans | not enforced, but strongly recommended | optional β overrides the saved proxy |
| Fansly | optional | optional |
## Supplying one [#supplying-one]
Pass it as a header on the connection routes:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
β¦
```
It is saved with the account. You do not send it again unless you want to override
it for a single request.
## Accepted formats [#accepted-formats]
```
http://user:pass@host:port
https://user:pass@host:port
http://host:port
socks5://user:pass@host:port
socks5h://user:pass@host:port # DNS resolved proxy-side
host:port:user:pass # compact form, converted to http://
```
The compact form splits on the first three colons, so a password containing a colon
survives.
`socks5h://` resolves hostnames at the proxy rather than locally, which is usually
what you want with SOCKS.
## Test before you connect [#test-before-you-connect]
```bash
curl -X POST "$BASE/api/crm/$CRM/proxy/test" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"proxy": "http://user:pass@host:port"}'
```
Worth doing first β a bad proxy during login looks like a login failure, and login
routes are limited to 100 requests/minute.
`/proxy/test` blocks loopback, private and link-local addresses as an SSRF guard, so
a proxy on your own machine reports as a failure rather than a pass.
## Changing an account's proxy [#changing-an-accounts-proxy]
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/proxy" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"proxy": "http://user:pass@newhost:port"}'
```
`GET` the same path to read the current one.
Swapping to a proxy that exits from a noticeably different city or country tends to
invalidate the session, because it looks exactly like an account takeover. Expect
to reconnect the account. Rotate within the same region if you must rotate.
## Choosing proxies [#choosing-proxies]
* **One proxy per account.** Sharing an IP across many creator accounts is a
reliable way to get all of them flagged together.
* **Residential or ISP proxies** behave better than datacenter ranges, which
OnlyFans is more aggressive about.
* **Sticky sessions**, not per-request rotation. A rotating proxy that changes IP
mid-session defeats the entire point.
## Credentials in logs [#credentials-in-logs]
Proxy URLs contain credentials. They are stored server-side with the account and
never reflected back except through `GET /accounts?include_session=true` and
`GET /accounts/{of_user_id}/proxy`, both of which need your API key. Server errors
are deliberately opaque so a proxy URL cannot leak through a stack trace β see
[Responses & errors](/docs/responses).
---
# Quickstart (/docs/quickstart)
You need two things before any call works: an **API key** and a **connected creator account**.
## Get an API key [#get-an-api-key]
Sign up at [theonlyapi.com](https://theonlyapi.com/pricing) and copy your key
from **Dashboard β Settings β API Credentials** (or **Dashboard β API Keys**).
The same screen shows your `crm_id`.
Keys look like a 43-character URL-safe string. Treat one like a password β it
grants full access to the panel.
Signing up provisions the panel and its first key together, so there is nothing
else to create. See [Get an API key](/docs/get-an-api-key).
## Set up your environment [#set-up-your-environment]
```bash
export BASE="https://theonlyapi.com"
export CRM="crm_xxxxxxxxxxxxxxxx" # your panel id
export KEY="YOUR_API_KEY" # your API key
export OFUID="482687148" # an of_user_id from GET /accounts (step 4)
```
## Connect a creator account [#connect-a-creator-account]
The recommended way is to paste session cookies once. Grab `sess` and `auth_id`
from a browser logged into OnlyFans (**DevTools β Application β Cookies β onlyfans.com**) and send them with a proxy:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
-H "Content-Type: application/json" \
-d '{
"platform": "onlyfans",
"sess": "",
"auth_id": ""
}'
```
We store the session server-side. You never send cookies again.
For Fansly, paste an auth token instead and the proxy is optional:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"platform": "fansly",
"auth_token": "",
"fansly_session_id": ""
}'
```
This is the short version. [Connect an account](/docs/connect-an-account)
covers the rest:
* **Credentials login** β email/username + password, with Cloudflare and
Turnstile handled for you
* **Two-factor** β what `requires_2fa` looks like and how to submit the code
(the field is `otp_code`, not `code`)
* **Proxies** β why OnlyFans needs one and what happens if you omit it
* **Connecting many accounts at once** via [bulk import](/docs/bulk-import)
* **Enabling write actions**, which are off until you turn them on
* **Disconnecting** and freeing the slot
## List your connected accounts [#list-your-connected-accounts]
```bash
curl "$BASE/api/crm/$CRM/accounts" \
-H "X-API-Key: $KEY"
```
Each entry carries an `of_user_id`. Despite the name it identifies an account on
**either** platform, and it is the `{of_user_id}` path parameter on every CRM route.
It is also the `user-id` **header** on the `/api2/v2/*` passthrough β the only route
in the API that reads that header β but that surface is OnlyFans-only: a `user-id`
belonging to a Fansly account returns `501 platform_not_supported`.
Each row also reports its `platform` and a `capabilities` object β the
authoritative per-account answer to "does this work on Fansly?". See
[OnlyFans & Fansly](/docs/platforms).
## Make a real call [#make-a-real-call]
A normalized CRM read β works for OnlyFans and Fansly:
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/balances" \
-H "X-API-Key: $KEY"
```
Or go straight through to OnlyFans, signed for you:
```bash
curl "$BASE/api/crm/$CRM/api2/v2/users/me" \
-H "X-API-Key: $KEY" \
-H "user-id: $OFUID"
```
No cookie header, no signing, no proxy rotation β the saved session does the work.
OnlyFans' own body comes back under `data`, inside a
`{ success, status_code, data }` envelope.
Those two calls are the two halves of the API, and they behave differently β
different response envelopes, different platform support. Read
[The two surfaces](/docs/two-surfaces) before you build on either.
## Where to go next [#where-to-go-next]
When to use the passthrough and when to use the CRM layer.
The `/cached` routes cost zero platform requests. Use them.
Get pushed new tips, subscribers and messages instead of polling.
What you can send, and the platform-side limits we do not enforce for you.
---
# Rate limits & quotas (/docs/rate-limits)
Three separate limits apply, and they are independent of each other.
## 1. Per-minute HTTP rate limits [#1-per-minute-http-rate-limits]
Anti-flood protection, applied on every plan. Keyed on your API key (falling
back to client IP when no key is present).
| Scope | Limit |
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Default β most reads, and the passthrough | **1000 / minute** |
| Sensitive β writes, key management, mass DMs, exports | **100 / minute** |
| Login β the three connection routes **and** all four `/api/auth/*` routes | **20 / minute** |
| `POST /api/auth/login` | additionally **6 / minute per email address**, stacked on the 20/minute above so an IP-rotating botnet still cannot brute-force one account |
| `GET /health`, `GET /events/stream` | exempt |
Exceeding one returns:
```json
{ "error": "Rate limit exceeded", "retry_after": "1000 per 1 minute" }
```
with HTTP `429`. Current state is exposed on `X-RateLimit-*` response headers, so
you can back off before hitting the wall.
Limits use a fixed window, so a burst that straddles a minute boundary can
briefly appear to allow double the nominal rate. Do not rely on that.
## 2. Monthly call quota [#2-monthly-call-quota]
Every counted request increments a per-panel monthly counter.
| Plan | Connected accounts | API calls / month |
| -------------------------------- | ------------------ | ----------------- |
| Free | 10 | 1,000 |
| Slots β $20/slot/mo ($15 at 15+) | 1 per slot | unlimited |
| Enterprise | custom | custom |
Check consumption:
```bash
curl "$BASE/api/crm/$CRM/usage" -H "X-API-Key: $KEY"
```
```json
{
"plan": "slots",
"api_calls_used": 18432,
"api_calls_limit": -1,
"accounts_used": 3,
"accounts_limit": 15
}
```
`GET /api/crm/{crm_id}/usage` is authoritative for your panel β read it rather than
assuming plan defaults. An `api_calls_limit` of `-1` means unlimited. Exhausting a
finite quota returns `429`.
Quota is not charged on every request: only calls that actually reach the platform
count. Cached reads, async-job status polls and background polling are free.
Adding an account beyond your slot count returns `403` (not 429):
```json
{ "error": "Account limit reached (1). Buy a slot to add another account.", "accounts_limit": 1 }
```
## 3. Platform-side limits β your responsibility [#3-platform-side-limits--your-responsibility]
This is the one that actually gets accounts banned, and **we do not enforce it for you.**
OnlyFans and Fansly rate-limit per account. Bursts of roughly 1 request/second
are fine; sustained traffic above about **5 requests/second** will get an account
flagged. Every `/api2/v2/*` call and every live (non-cached) CRM read reaches the
platform.
Prefer the `/cached` routes, which serve from our own store and cost **zero**
platform requests:
`/subscribers/cached` Β· `/subscribers/new` Β· `/subscribers/stats` Β·
`/transactions/cached` Β· `/fans/{fan_id}/transactions/cached` Β·
`/campaigns/{campaign_id}/claimers/cached`
Refresh them on a schedule with the async
[refresh jobs](/docs/async-jobs) rather than reading live in a loop. See
[Cached reads](/docs/cached-reads).
## Handling 429 correctly [#handling-429-correctly]
```python
import time, requests
def call(session, method, url, key, **kw):
for attempt in range(6):
r = session.request(method, url, headers={"X-API-Key": key}, timeout=60, **kw)
if r.status_code != 429:
return r
# Prefer the server's hint; otherwise back off exponentially.
wait = float(r.headers.get("Retry-After") or 2 ** attempt)
time.sleep(min(wait, 60))
return r
```
Distinguish the two `429` causes before retrying: a per-minute limit clears
within the minute, but an exhausted **monthly quota** will not clear by retrying
at all. Check the `error` string, or call `/usage`.
## Exemptions [#exemptions]
Panels can be granted a rate-limit exemption or a quota override. Both are set
server-side by us, not through the API β contact support if your workload needs
one.
---
# Responses & errors (/docs/responses)
## Two envelopes [#two-envelopes]
Which envelope you get depends on which [surface](/docs/two-surfaces) you are on.
Data is a **sibling key** alongside `success`, named after the resource:
```json
{
"success": true,
"list": [ β¦ ],
"total": 412,
"limit": 100,
"offset": 0,
"hasMore": true
}
```
This is the convention, not a guarantee. Some CRM routes return a bare object or
a bare array with no `success` field. Do not write a client that requires
`success` to be present on every CRM response β check the individual endpoint
page.
Always wrapped, always the same three keys:
```json
{
"success": true,
"status_code": 200,
"data": { "β¦OnlyFans' own payloadβ¦" }
}
```
`data` holds OnlyFans' response verbatim, including its own `list` / `hasMore`
pagination fields. A `relogin: true` key is added when the session had to be
refreshed to serve the request.
On failure:
```json
{
"success": false,
"status_code": 401,
"data": { "error": { "code": 401, "message": "Unauthorized" } }
}
```
## Error shapes [#error-shapes]
There is **no single error envelope**. In practice you will see three shapes:
```json
// Most handlers β a bare error string
{ "error": "Invalid API key" }
// Validation failures
{ "error": "limit must be at most 500" }
// Uncaught server errors
{ "error": "Internal server error", "correlation_id": "a1b2c3d4" }
```
Some routes additionally include `success: false`, and some include a machine
`code`:
```json
// Mass messaging β the one route using an UPPERCASE code
{ "success": false, "code": "PLATFORM_NOT_SUPPORTED", "error": "mass messaging is OnlyFans-only for now" }
// Every other 501 β lowercase, with `platform` and `feature`
{ "success": false, "code": "platform_not_supported",
"error": "\"referrals\" is not available for fansly accounts yet.",
"platform": "fansly", "feature": "referrals" }
```
Compare that code case-insensitively β branching on the uppercase spelling alone
misses every 501 except mass DMs.
Read `error` as the human message, and treat `success`, `code` and
`correlation_id` as optional. Do not branch on `success` being present.
Rate limiting and quota errors carry extra fields:
```json
{ "error": "Rate limit exceeded", "retry_after": "1000 per 1 minute" }
{ "error": "API call limit reached", "plan": "only-api-free", "limit": 1000, "used": 1000, "upgrade_url": "β¦" }
// 403, not 429
{ "error": "Account limit reached (10). Buy a slot to add another account.",
"code": "SLOT_LIMIT", "plan": "only-api-free",
"accounts_used": 10, "accounts_limit": 10, "slot_purchase_url": "β¦" }
```
`500` responses are deliberately opaque β the full traceback is logged
server-side only, so SQL fragments, file paths, proxy credentials and session
tokens never reflect back to you. Quote the `correlation_id` when reporting one.
## Status codes [#status-codes]
| Code | Meaning |
| ----- | ---------------------------------------------------------------------------------------------------------------- |
| `200` | Success |
| `202` | Async job accepted β poll its status route. See [Async jobs](/docs/async-jobs) |
| `400` | Validation error, or a missing required header |
| `401` | No `X-API-Key` sent, bad dashboard credentials, or a session that could not be re-established (`relogin_failed`) |
| `403` | **Invalid** key, wrong panel, primary-key-only route, write actions disabled, or the account slot limit |
| `404` | Unknown job or, on a few account routes, an account that is not yours |
| `409` | An async job of that kind is already running for this account |
| `429` | Per-minute rate limit or monthly quota |
| `500` | Server error β includes a `correlation_id` |
| `501` | The operation is not supported for that account's platform |
## The `_notice` wrapper means you are on a decoy path [#the-_notice-wrapper-means-you-are-on-a-decoy-path]
Some URLs on this domain are honeypot bait rather than API routes β notably
`/api/health`, `/api/v1/*`, `/api/internal/*`, and paths like `/.env` and
`/.git/config`. Those return **procedurally-generated fake data** with a `_notice`
string prepended (and a top-level array reshaped into `{_notice, data: [β¦]}`), plus
`X-Honeypot-*` and `X-AI-Notice` headers.
The decoy is selected by **path**, not by authentication. `GET /api/health` is bait
and reports a fabricated version and service list; the real health check is
`GET /health` on `api.theonlyapi.com`.
The real API is panel-scoped: `https://theonlyapi.com/api/crm/{crm_id}/β¦`. A missing
or wrong key there gives you a plain `401`/`403` JSON error with no `_notice`.
There is **no User-Agent check** on the API. Testing with `curl` needs no `-A`
flag β the User-Agent only appears echoed inside a decoy notice, and never changes
which response you get.
## Other headers [#other-headers]
Every response carries `Cache-Control: no-store` plus a standard security header
set (HSTS, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy`). Rate-limit state is exposed via `X-RateLimit-*` headers.
CORS is restricted for `/api/*`; allowed request headers are `Content-Type`,
`X-API-Key`, `X-Proxy` and `Authorization`. If you are calling from a browser
origin, proxy through your own backend instead.
---
# Sessions & re-login (/docs/sessions)
A connected account's session lives on our side, scoped to your panel. You send
an `of_user_id`; we load the cookies, `x-bc` and `x-hash`, route through the
account's proxy, sign the request, and forward it.
## What we store [#what-we-store]
| Stored | Used for |
| ---------------------------------------------------------------- | ------------------------------- |
| Session cookies (`sess`, `auth_id`, `fp`) or a Fansly auth token | Authenticating as the account |
| `x-bc`, `x-hash` | OnlyFans request signing |
| The proxy supplied at connect time | Keeping the session's IP stable |
| Credentials, when you connected with them | Automatic re-login |
Retrieve the session block for an account with
`GET /accounts?include_session=true`. You rarely need to β the point of the API
is that you do not handle these values.
## Automatic re-login [#automatic-re-login]
Platforms invalidate sessions for all sorts of reasons: a password change, a
suspicious-activity lockout, a login from elsewhere, or simple expiry.
When we detect an invalidated session mid-request, we re-login using the stored
credentials and retry the request transparently. When that happens on the
passthrough, the response carries an extra flag:
```json
{ "success": true, "status_code": 200, "relogin": true, "data": { β¦ } }
```
`relogin: true` means the call succeeded, but the session behind it was rebuilt.
Nothing is required of you β it is a signal, not an error.
## When re-login fails [#when-re-login-fails]
If we cannot recover the session β no stored credentials, credentials no longer
valid, or 2FA now required β the endpoint returns `401` with `relogin_failed`.
Once the credential circuit-breaker trips it also carries `needs_reconnect: true`
and `reason: "invalid_credentials"`.
`needs_reconnect` is the actionable flag, and it is also surfaced per account on
`GET /accounts` β poll that rather than waiting for a call to fail. The account needs
reconnecting:
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/login/cookies" \
-H "X-API-Key: $KEY" \
-H "X-Proxy: http://user:pass@host:port" \
-H "Content-Type: application/json" \
-d '{"platform":"onlyfans","sess":"","auth_id":""}'
```
After five consecutive failures, background polling for that account is paused
automatically and a `polling_paused` event is emitted with a `reason` and a
`failures` count. Subscribe to it β it is the earliest reliable signal that an
account needs attention. See [Events](/docs/events).
Reconnecting clears the **re-login block**, but **not** the polling pause.
Re-enable it explicitly:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/polling" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"enabled": true}'
```
The failure counter only resets on a *successful* poll, so if the underlying problem
is still there the first failure after re-enabling re-trips the threshold and pauses
it again.
## Detecting it before your users do [#detecting-it-before-your-users-do]
Two approaches, in order of preference:
1. **Subscribe to** `polling_paused` via [webhooks](/docs/webhooks) or
[SSE](/docs/streaming). You find out within a poll cycle.
2. **Watch for** `relogin_failed` on your own calls, and surface it as a
reconnect prompt rather than a generic error.
## Why the proxy matters [#why-the-proxy-matters]
OnlyFans associates a session with the network it was created from. If requests
for that session start arriving from a different IP, it gets invalidated β which
is why `X-Proxy` is required when connecting an OnlyFans account, and why we route
every subsequent request for that account through the same proxy.
Changing an account's proxy after the fact is supported:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/accounts/$OFUID/proxy" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"proxy": "http://user:pass@newhost:port"}'
```
Expect the session to need re-establishing if the new proxy exits from a
noticeably different location. See [Proxies](/docs/proxies).
---
# Server-sent events (/docs/streaming)
```
GET /api/crm/{crm_id}/events/stream
```
A long-lived `text/event-stream` connection carrying [events](/docs/events) for
your panel as they are emitted, plus `export.progress` and `export.complete` for
running [exports](/docs/exports).
This endpoint is **exempt from rate limiting** β a persistent connection would
otherwise burn your per-minute budget immediately.
## Connecting [#connecting]
```bash
curl -N "$BASE/api/crm/$CRM/events/stream" \
-H "X-API-Key: $KEY"
```
```
: connected
id: 12345
event: new_tip
data: {"id":12345,"event_type":"new_tip","crm_id":"crm_β¦","of_user_id":"1234567","occurred_at":"β¦","created_at":"β¦","source_event_id":"tx:99","payload":{β¦}}
event: export.progress
data: {"event_type":"export.progress","payload":{"job_id":"f01152d3β¦","status":"running","phase":"messages","phase_index":6,"phase_total":7,"counts":{β¦}}}
: keep-alive
```
Lines beginning with `:` are comments β one `: connected` on open, then
`: keep-alive` every 15 seconds. Ignore them.
Account events are the flat [event envelope](/docs/events) and carry an `id:` line.
**Progress frames (`export.*`, `import.*`, `refresh.*`) nest everything under `payload`** and have no `id`, `crm_id` or `occurred_at`. A client that assumes the
envelope will crash on them.
## Filtering server-side [#filtering-server-side]
```
GET /events/stream?types=new_tip,import.progress
```
An unknown type is a `400`; omitting it (or `*`) means everything. This is the only
way to subscribe to just the job-progress events.
```python
import json, requests
with requests.get(
f"{BASE}/api/crm/{CRM}/events/stream",
headers={"X-API-Key": KEY, "Accept": "text/event-stream"},
stream=True,
timeout=None,
) as r:
r.raise_for_status()
event_type = None
for line in r.iter_lines(decode_unicode=True):
if not line: # blank line terminates an event
event_type = None
elif line.startswith(":"): # keep-alive
continue
elif line.startswith("event:"):
event_type = line[6:].strip()
elif line.startswith("data:"):
handle(event_type, json.loads(line[5:].strip()))
```
```js
// Node's global fetch streams fine and lets you set headers,
// which EventSource cannot do.
const res = await fetch(`${BASE}/api/crm/${CRM}/events/stream`, {
headers: { 'X-API-Key': KEY, Accept: 'text/event-stream' },
});
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let split;
while ((split = buffer.indexOf('\n\n')) !== -1) {
const chunk = buffer.slice(0, split);
buffer = buffer.slice(split + 2);
let type, data;
for (const line of chunk.split('\n')) {
if (line.startsWith('event:')) type = line.slice(6).trim();
else if (line.startsWith('data:')) data = line.slice(5).trim();
}
if (data) handle(type, JSON.parse(data));
}
}
```
The browser `EventSource` API **cannot set request headers**, so it cannot send
`X-API-Key`. Never put your key in a query string or in client-side JavaScript
either β it grants full access to your panel.
Proxy the stream through your own backend, attaching the key server-side:
```ts
// app/api/events/stream/route.ts (Next.js)
export async function GET() {
const upstream = await fetch(
`${process.env.CRM_API_BASE}/api/crm/${process.env.CRM_ID}/events/stream`,
{ headers: { 'X-API-Key': process.env.CRM_API_KEY! } },
);
return new Response(upstream.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
},
});
}
```
Then in the browser:
```js
const es = new EventSource('/api/events/stream');
es.addEventListener('new_tip', (e) => handle(JSON.parse(e.data)));
```
## Reconnecting [#reconnecting]
Unlike [webhooks](/docs/webhooks), the stream does not retry or replay. Anything
emitted while you were disconnected is gone from your point of view.
There is also a bounded **200-event queue per connection**: if you read more slowly
than we emit, the overflow is dropped silently. A client that must not miss events
should reconcile against `GET /events`, not rely on the stream alone.
Reconnect with exponential backoff and backfill from
`GET /events?since=`. Track `created_at` β `since` is an
exclusive bound on our insert time, whereas `occurred_at` is the platform's own
timestamp and can be much older.
## SSE or webhooks? [#sse-or-webhooks]
| | SSE | Webhooks |
| --------------------- | --------------------------------------- | -------------------------------------------------------- |
| Needs a public URL | no | yes |
| Retries | no | `5s β 30s β 5m β 30m β 2h` |
| Survives your restart | no | yes |
| Latency | lowest | low |
| Good for | dashboards, live UI, watching an export | server-side automation, anything that must not be missed |
Use SSE for anything a human is watching. Use webhooks for anything that has to be
reliable. Using both is reasonable: SSE to update the UI instantly, webhooks as the
durable path.
## Behind a proxy [#behind-a-proxy]
The response already sets `X-Accel-Buffering: no`, which nginx honours. For other
proxies or CDNs, disable response buffering for this route or events arrive in
batches. Cloudflare buffers `text/event-stream` on some
plans; keep-alive comments mitigate but do not eliminate this.
---
# Subscribers & fans (/docs/subscribers)
Two related resources. **Subscribers** are per-account subscription records.
**Fans** are the panel-wide CRM view of people, with tags, notes and spend.
## Subscribers [#subscribers]
```bash
# Cached β zero platform requests, reliable total
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/cached?limit=100&offset=0" \
-H "X-API-Key: $KEY"
# Live β hits the platform
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers" -H "X-API-Key: $KEY"
```
Default to cached. See [Cached reads](/docs/cached-reads).
### Newly seen subscribers [#newly-seen-subscribers]
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/new" -H "X-API-Key: $KEY"
```
Every incoming subscription β new subs **and renewals**, because a renewal moves the
fan's `subscribed_at` forward β newest first, from cache at zero platform cost.
There is no sync cursor. Without `since`/`until` you get the newest **50** of the
entire cache. Accepts `since`, `until` (inclusive ISO bounds on `subscribed_at`),
`limit` (1β500, default 50), `offset`, and `type` (`all`|`active`|`expired`).
For a genuine "what is new since I last looked" cursor, use
`GET /events?types=new_subscriber&since=β¦` instead.
### Statistics [#statistics]
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/stats?granularity=day" \
-H "X-API-Key: $KEY"
```
`granularity` is `hour`, `day`, `week` or `month`. Aggregated server-side, so this
is one cheap request rather than bucketing thousands of rows yourself.
### Keeping it fresh [#keeping-it-fresh]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/subscribers/refresh" -H "X-API-Key: $KEY"
# 202 β poll .../subscribers/refresh/status
```
A delta sync, so it is cheap enough to schedule. See [Async jobs](/docs/async-jobs).
The `expired_subscriber` event has no fan identity β only `previous_total`,
`new_total` and a **negative** `delta`, sampled every 10th poll.
But you do not need to diff snapshots to recover the *who*:
`GET /subscribers/cached?type=expired` returns them directly with each row's
`expired_at`, because `is_active` is recomputed from `expired_at` on every read
rather than frozen at sync time.
## Fans [#fans]
Panel-wide, across every connected account:
```bash
curl "$BASE/api/crm/$CRM/fans?limit=100&offset=0" -H "X-API-Key: $KEY"
```
Each fan carries identity, tags, notes and `total_spend`. That figure is
`MAX(platform lifetime total, sum of captured tip/purchase events)` β **not** the
chargeback-corrected signed sum. Check `spend_known`: when it is `0`, spend is
unknown rather than zero. See [Earnings & transactions](/docs/earnings).
### Tags [#tags]
```bash
# Add β of_user_id is REQUIRED in the body
curl -X POST "$BASE/api/crm/$CRM/fans/$FANID/tags" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"tag": "vip", "of_user_id": "'$OFUID'"}'
# Remove β of_user_id in the body or the query string
curl -X DELETE "$BASE/api/crm/$CRM/fans/$FANID/tags/vip?of_user_id=$OFUID" \
-H "X-API-Key: $KEY"
```
Tags are how you segment, and they can be applied automatically β a `tag_fan`
[automation](/docs/automations) on `new_purchase` with an amount condition builds a
VIP list with no code.
Tags are **not** a mass-DM audience filter. To message a tagged segment, read the
ids from `GET /fans` and pass them as `audience.fan_ids` β see
[Messaging](/docs/messaging).
### Notes [#notes]
```bash
curl -X PUT "$BASE/api/crm/$CRM/fans/$FANID/note" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"note": "Prefers video content.", "of_user_id": "'$OFUID'"}'
```
One free-text note per fan, replaced on write; max 2000 characters, and an empty
string clears it.
The `fans` table is unique on `(crm_id, of_user_id, fan_of_user_id)`, so the
panel-wide path alone cannot identify a row. All three of these routes return `400`
without `of_user_id`. Tags cap at 40 characters.
### Refreshing one fan's profile [#refreshing-one-fans-profile]
```bash
curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/fans/$FANID/refresh-profile" \
-H "X-API-Key: $KEY"
```
Pulls that fan's current profile from the platform. Targeted, so it costs one
platform request rather than a full re-walk β good for a "refresh" button in a UI.
### Per-fan transactions [#per-fan-transactions]
```bash
curl "$BASE/api/crm/$CRM/accounts/$OFUID/fans/$FANID/transactions/cached" \
-H "X-API-Key: $KEY"
```
## A common workflow [#a-common-workflow]
### Refresh, then read [#refresh-then-read]
Kick off `subscribers/refresh` and `transactions/refresh`, then read from the
cached routes. Do not block on the refresh β the cache always answers.
### Tag automatically [#tag-automatically]
An automation on `new_purchase` with `payload.amount >= 100` and a
`tag_fan` action keeps a `vip` segment current without any code.
### Segment and act [#segment-and-act]
Read `GET /fans`, filter by tag and spend, then send a targeted
[mass DM](/docs/messaging) β with `dry_run: true` first to check the reach.
---
# The two surfaces (/docs/two-surfaces)
The API has two surfaces with genuinely different contracts. Picking the wrong
one is the most common source of confusion.
## 1. Transparent passthrough β `/api2/v2/*` [#1-transparent-passthrough--api2v2]
**OnlyFans only.** A thin, signed proxy. OnlyFans' own response body is passed
through unchanged, but always wrapped in a small envelope:
```json
{
"success": true,
"status_code": 200,
"data": { "β¦OnlyFans' actual payloadβ¦" }
}
```
Check `success` first, then read the OnlyFans payload β including `list` and
`hasMore` on paginated endpoints β from `data`. On error, `success` is `false`,
`status_code` carries the upstream HTTP status, and `data` holds an `error`
object with `code` and `message`. A `relogin: true` field appears when the
session was refreshed mid-request.
Each request needs:
| Header | Required | Purpose |
| ----------- | -------- | --------------------------------------------------------------------------------------- |
| `X-API-Key` | yes | Your panel key |
| `user-id` | yes | Which connected account to act as. Omitting it returns `400 user-id header is required` |
| `X-Proxy` | no | Overrides the proxy saved at login time |
```bash
curl "$BASE/api/crm/$CRM/api2/v2/users/me" \
-H "X-API-Key: $KEY" \
-H "user-id: 482687148"
```
### What we do for you [#what-we-do-for-you]
1. Load the saved session for that account (cookies, `x-bc`, `x-hash`)
2. Route the request through the account's proxy, so OnlyFans sees the same IP
that logged in
3. Generate fresh signed headers server-side using the current OnlyFans signing
algorithm
4. Forward to `onlyfans.com/api2/v2/β¦`
5. Return the JSON response
Two different failures are worth telling apart:
| Code | Meaning |
| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| `403 Account not found or does not belong to this CRM panel` | The `of_user_id` is not connected to your panel |
| `401 No session found. Provide sess and auth_id cookies (-b) on first request, or login via /accounts/login/cookies` | It *is* your account, but there is no stored session |
The passthrough does have a cookie bootstrap for that second case β send `sess` and
`auth_id` as cookies on the first request and a session is created. Prefer
`POST /accounts/login/cookies`, which stores it once and properly.
## 2. Normalized CRM layer [#2-normalized-crm-layer]
**OnlyFans + Fansly.** Here responses *are* normalized: both platforms' rows are
projected to the same shape, and several routes are server-side aggregations with
no platform equivalent.
* **Cached reads that cost zero platform requests** β `/subscribers/cached`,
`/subscribers/new`, `/subscribers/stats`, `/transactions/cached`,
`/fans/{fan_id}/transactions/cached`, `/campaigns/{campaign_id}/claimers/cached`
* **Async refresh and backfill jobs** β `POST /subscribers/refresh`,
`/transactions/refresh`, `/campaigns/refresh`, `/backfill`. Each returns `202`;
poll the matching `/refresh/status` route or listen on `/events/stream`
* **Cross-account aggregation** β `GET /earnings/summary`, `GET /balances/summary`
* **Fan CRM** β `GET /fans` with tags, notes and per-fan cached transactions
* **Messaging** β `/chats`, `/chats/{with_user_id}/messages`, `POST /messages/mass`,
`/ppv-stats`
* **Data exports** β `/exports`, producing a downloadable ZIP
* **Webhooks** β `/webhooks` with a delivery log and test fire
* **Automations** β event-triggered actions, `/run-now`, run history
* **Realtime** β `GET /events` (poll) and `GET /events/stream` (SSE)
The response envelope is different: data is a **sibling key**, not nested under
`data`.
```json
{
"success": true,
"list": [ β¦ ],
"total": 412,
"limit": 100,
"offset": 0,
"hasMore": true
}
```
See [Responses & errors](/docs/responses) for the full picture, including the
routes that return a bare object with no envelope at all.
## Which should I use? [#which-should-i-use]
Default choice. Works on both platforms, cheaper (cached reads cost no
platform requests), stable shapes, and it is where events, webhooks,
automations and exports live.
When you need an OnlyFans capability the CRM layer does not expose β vault
media, stories, streams, promotions, granular post management.
GET is always allowed. Any other method through `/api2/v2/*` is a write performed as
the creator, so it requires `allow_of_write_actions` on that account and otherwise
returns `403` with `code: "WRITES_DISABLED"` β the same gate as the CRM write routes.
See [Connect an account](/docs/connect-an-account).
Passthrough calls hit OnlyFans directly and count against *their* rate limits,
which we do not enforce for you. Roughly 1 request/second in bursts is fine;
sustained traffic above \~5 req/s risks getting the account flagged. Prefer the
`/cached` routes. See [Rate limits & quotas](/docs/rate-limits).
---
# Webhooks (/docs/webhooks)
Subscribe an HTTPS endpoint and we will POST events to it as they happen.
## Create one [#create-one]
```bash
curl -X POST "$BASE/api/crm/$CRM/webhooks" \
-H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/theonlyapi",
"event_types": ["new_subscriber", "new_tip"],
"description": "production worker"
}'
```
| Field | Required | Notes |
| ------------- | -------- | ----------------------------------------------------- |
| `url` | yes | `http://` is accepted, but see the HTTPS note below |
| `event_types` | **yes** | Must be a non-empty array. Use `["*"]` for every type |
| `description` | no | Free-text label, max 200 characters |
The field is `event_types` β not `events`. A body using `events` is silently
ignored, which then fails the non-empty check and returns
`400 event_types must be a non-empty list`.
There is also **no `of_user_id` field** and no `enabled` field on create. See
below.
The response includes the generated signing `secret` β store it, it is what you
verify deliveries with. It is also returned by `GET /webhooks` and
`GET /webhooks/{id}`.
## Approval β a new webhook may deliver nothing [#approval--a-new-webhook-may-deliver-nothing]
This is the first thing to check when a webhook seems dead.
A webhook is created with a `status` of either `approved` or `pending`, returned
in the create response. **A `pending` webhook receives no deliveries at all.**
These hosts are approved immediately:
```
discord.com discordapp.com hooks.slack.com api.telegram.org
```
So is any host an admin has already approved for your panel. **Every other host starts as** `pending` β including your own domain, the first time.
| Symptom | Cause |
| ----------------------------------------------------------------------------------------------- | ---------------------------- |
| `POST /webhooks/{id}/test` returns `403 Webhook is awaiting admin approval and cannot send yet` | `status: "pending"` |
| Events appear in `GET /events` but never arrive at your URL | `status: "pending"` |
| `status: "rejected"` with a `reject_reason` | An admin declined the domain |
Check `status` on `GET /webhooks/{id}`. If it is `pending`,
[contact support](https://theonlyapi.com/contact) to get the domain reviewed.
Once a host is approved for your panel, further webhooks on that same host are
approved automatically. Repointing a webhook at a **different** host puts it back
into review; changing only the path does not.
## Webhooks are panel-wide [#webhooks-are-panel-wide]
Every connected account's events go to every matching webhook. There is no
per-webhook account filter β the `webhooks` table has no `of_user_id` column.
If you need per-account routing, either filter on `of_user_id` in your handler,
or use an [automation](/docs/automations), which *does* accept `of_user_id`.
## Delivery format [#delivery-format]
Every delivery is a `POST` with these headers:
| Header | Value |
| ----------------------- | --------------------------------------------- |
| `Content-Type` | `application/json` |
| `User-Agent` | `TheOnlyAPI-Webhook/1.0` |
| `X-OnlyAPI-Signature` | `sha256=` |
| `X-OnlyAPI-Timestamp` | Unix seconds β the same value that was signed |
| `X-OnlyAPI-Event` | The event type, e.g. `new_tip` |
| `X-OnlyAPI-Delivery-Id` | Unique per attempt |
The body is exactly six keys β `id`, `event_type`, `crm_id`, `of_user_id`,
`occurred_at`, `payload`. (The SSE stream and `GET /events` add `source_event_id`
and `created_at`; the webhook body is trimmed to these six.) See
[Events](/docs/events) for payload shapes.
## Verifying the signature [#verifying-the-signature]
The signed message is **not** the body alone. It is the timestamp, a literal `.`,
then the raw request body:
```
message = "{X-OnlyAPI-Timestamp}." + raw_body
signature = "sha256=" + HMAC_SHA256(webhook_secret, message).hexdigest()
```
We serialise with compact separators (`,` and `:`, no spaces). Re-serialising
parsed JSON will not reproduce those bytes and the signature will not match.
Capture the body as bytes first, verify, then parse.
```python
import hmac, hashlib
def verify(secret: str, timestamp: str, raw_body: bytes, received: str) -> bool:
message = f"{timestamp}.".encode() + raw_body
expected = "sha256=" + hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, received)
# Flask
@app.post("/hooks/theonlyapi")
def hook():
raw = request.get_data() # bytes, before parsing
ok = verify(SECRET,
request.headers["X-OnlyAPI-Timestamp"],
raw,
request.headers["X-OnlyAPI-Signature"])
if not ok:
return "", 401
event = request.get_json()
...
return "", 200
```
```js
import crypto from 'node:crypto';
function verify(secret, timestamp, rawBody, received) {
const message = Buffer.concat([Buffer.from(`${timestamp}.`), rawBody]);
const expected =
'sha256=' + crypto.createHmac('sha256', secret).update(message).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(received ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express β note express.raw(), NOT express.json()
app.post(
'/hooks/theonlyapi',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verify(SECRET, req.get('X-OnlyAPI-Timestamp'), req.body, req.get('X-OnlyAPI-Signature'))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
},
);
```
```go
func verify(secret, timestamp string, rawBody []byte, received string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(received))
}
```
Always use a constant-time comparison (`hmac.compare_digest`,
`crypto.timingSafeEqual`, `hmac.Equal`) rather than `==`.
## Retries and auto-deactivation [#retries-and-auto-deactivation]
A delivery is retried on this ladder, up to 6 attempts total:
```
5s β 30s β 5m β 30m β 2h
```
Those are **lower bounds** β a scheduler job sweeps due retries every 10 seconds
and processes at most 50 per pass.
A delivery only counts as a *failure* once it has exhausted the whole ladder, or
hit a non-retryable error. After **five such consecutive failures** the webhook is
set inactive and stays that way until you re-enable it:
```bash
curl -X PATCH "$BASE/api/crm/$CRM/webhooks/$WEBHOOK_ID" \
-H "X-API-Key: $KEY" -H "Content-Type: application/json" \
-d '{"is_active": true}'
```
Setting `is_active: true` also resets the failure counter.
Because each failure must walk the full \~2h ladder first, auto-deactivation takes
well over half a day of sustained downtime β but it also means a webhook can be
quietly failing for hours before anything gives up. Watch
`GET /webhooks/{id}/deliveries?limit=50` (max 200), or reconcile against
[`GET /events`](/docs/events).
Retried rows appear in the log with status `superseded` β that is bookkeeping, not
a failure.
### Non-retryable failures [#non-retryable-failures]
| Response | Behaviour |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `3xx` | **Terminal, never retried.** We send with redirects disabled and refuse to carry a signed payload to a `Location` we did not validate (`redirect refused`) |
| Private / loopback target | Blocked *before* the request is made β logged as `blocked: ` |
| Any other non-2xx | Retried on the ladder |
## Writing a good handler [#writing-a-good-handler]
* **Return 2xx fast, and never 3xx.** Acknowledge, enqueue, process
asynchronously.
* **Be idempotent.** Retries mean the same event can arrive more than once. Dedupe
on the event `id`.
* **Verify before you trust.** The URL is public; the signature is what makes the
payload yours.
* **Use HTTPS.** `http://` is accepted, but the payload carries fan PII and the
signature authenticates it without encrypting it.
## Testing locally [#testing-locally]
```bash
curl -X POST "$BASE/api/crm/$CRM/webhooks/$WEBHOOK_ID/test" -H "X-API-Key: $KEY"
```
Fires a real, signed delivery so you can validate the whole path including
signature verification. It returns `403` if the webhook is still `pending`.
Deliveries to `localhost`, `127.0.0.1` and any private or link-local address are
blocked before the request is made, and DNS failures fail closed. Use `ngrok` or
`cloudflared` while developing β and remember the tunnel host will start as
`pending` unless it has been approved for your panel.
## Routes [#routes]
```
GET /webhooks # list, each with its secret and status
POST /webhooks # create
GET /webhooks/{webhook_id} # read one
PATCH /webhooks/{webhook_id} # url, event_types, description, is_active
DELETE /webhooks/{webhook_id}
POST /webhooks/{webhook_id}/test # fire a real signed delivery
GET /webhooks/{webhook_id}/deliveries?limit=50 # delivery log, max 200
```