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