# BetterFans Link: the OnlyFans API. Complete Documentation > This document contains the complete developer documentation for the BetterFans Link SDK (`@betterfans/link-sdk`), an OnlyFans API for developers and agencies, built on the infrastructure behind OFManager (https://ofmanager.com), the OnlyFans agency management platform. Use this to build OnlyFans agency tools, CRMs, dashboards, chatbots, and automations. BetterFans Link is not affiliated with OnlyFans or Fenix International Limited. > betterfans.link is where you create your account and, from the dashboard, link your OnlyFans creators and get an API key (creator linking and keys open soon during early access). Once set up, you have full access through the BetterFans Link SDK. All routing, proxying, session management, and rate limits are handled by the API. > **Designed for AI-assisted development.** The SDK is fully typed so AI coding tools (Cursor, Claude Code, Replit, etc.) can autocomplete every route, parameter, and response. Get an API key, link your creators, and vibe code your own OnlyFans applications. > **To get started:** 1) Create an account at https://betterfans.link/signup 2) Link your OnlyFans creator accounts 3) Install `@betterfans/link-sdk` 4) Open Cursor, Claude Code, or any AI tool and start prompting. For custom/enterprise needs, contact https://t.me/ofmanagercom --- # BetterFans Link SDK — The OnlyFans API The BetterFans Link SDK is the only developer API for the OnlyFans platform — the infrastructure behind OFManager, the leading OnlyFans agency management platform. Build OnlyFans agency tools, chatbots, CRMs, and automations. The complete OnlyFans API in a single SDK. Typed endpoints, realtime events across every account, and media uploads — in 50 lines, not 5,000. The BetterFans Link SDK is the only developer API for the OnlyFans platform — the infrastructure behind [OFManager](https://ofmanager.com), the leading OnlyFans agency management platform managing hundreds of creator accounts and processing millions of events per day. It gives you direct, typed access to every OnlyFans API endpoint so you can build automations, integrations, and features on top of this same production infrastructure. ## A working multi-account OnlyFans chat app in 50 lines Building a reliable multi-tenant chat layer from scratch is weeks of boilerplate. The BFL SDK gives you the complete abstraction out of the box. One connection, all your accounts, fully typed. ```ts import { OfApiClient, OfWsClient } from "@betterfans/link-sdk" import { media } from "@betterfans/link-sdk/plugins/media" // 1. Initialize your client with Media capabilities const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, plugins: [media()], }) // 2. Connect a single WebSocket EventBus for ALL your managed accounts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message"], // Only listen for new messages }) // 3. Listen to events across every account simultaneously ws.onEnvelope("chat_message", async (ctx) => { if (!ctx.accountId) return const msg = ctx.payload.api2_chat_message console.log(`New message on account ${ctx.accountId} from user ${msg.fromUser?.id}`) // 4. Automatically scope API calls to the specific account const account = client.for(ctx.accountId) // 5. Attach a photo in a single call — the plugin handles the rest const media = await account.plugin.media.upload({ source: "https://cdn.example/welcome.jpg", }) // 6. Send a fully-typed reply with the attached media const [error, message] = await account.request("POST /chats/:id/messages", { pathParams: { id: msg.fromUser!.id.toString() }, body: { text: "Thanks for reaching out! Here's a welcome gift.", mediaFiles: media.kind === "existing" ? [media.mediaId] : [media.ref], }, }) }) ws.connect() ``` --- # Why BetterFans Link SDK? Why the BetterFans Link SDK — the infrastructure behind OFManager — is the standard OnlyFans API for building applications, chatbots, and agency tools at scale. Building on top of OnlyFans is a long road. The BetterFans Link SDK — the only developer API for the OnlyFans platform — ships the complete, production-ready infrastructure stack so you don't have to build it yourself. It is the infrastructure behind [OFManager](https://ofmanager.com), the leading OnlyFans agency management platform managing hundreds of creator accounts. By using the BetterFans Link SDK, you build your application on top of the exact same proprietary, hardened OnlyFans API infrastructure that already processes millions of events per day. ## Built for Agency Scale (Multi-Account) If you manage 100 creators, managing individual connections and polling logic for every account is an architectural nightmare. The BetterFans Link SDK's OnlyFans API solves this through its **scoped client architecture**: ```ts const client = new OfApiClient({ apiKey }) // Get an isolated client for any managed account instantly const creatorA = client.for("111111") const creatorB = client.for("222222") // A single WebSocket connection receives events for ALL 100 creators const ws = new OfWsClient({ apiKey, wsToken }) ws.onEnvelope("chat_message", (ctx) => { console.log(`Message on account ${ctx.accountId}`) }) ``` ## Core Capabilities, Not Just Wrappers We don't just wrap HTTP calls; we collapse entire infrastructure layers into single methods through our **Core Capabilities (Plugins)**. For example, attaching a video to a message usually means orchestrating a whole staging and processing pipeline. With the BetterFans SDK Media plugin, it's a single call: ```ts const result = await account.plugin.media.upload({ source: videoUrl }) await account.request("POST /chats/:id/messages", { body: { mediaFiles: result.kind === "existing" ? [result.mediaId] : [result.ref], }, }) ``` ## 100% Type Safety We type every route string, query parameter, request body, and response field. Instead of spending hours reading documentation to figure out what fields exist on a response object, your editor knows exactly what `GET /users/me` returns, and will throw a compile-time error if you typo a field name. One docs page covers the entire API, because the types *are* the documentation. --- # Getting OnlyFans API Access How to get OnlyFans API access. Create a BetterFans Link account, link your creators, and issue an SDK key from your dashboard. The BetterFans Link SDK — the only developer API for the OnlyFans platform, the infrastructure behind [OFManager](https://ofmanager.com). Create an account, link your creators, and issue an SDK key. ## Create an account Sign up at [betterfans.link/signup](https://betterfans.link/signup). No approval process, no waiting. > Accounts are open now. Linking creators and issuing SDK keys open in the dashboard soon, and every account is emailed when they do. ## Link your creators Connect your OnlyFans creator accounts under **OnlyFans accounts** in your dashboard. Once linked, those accounts are reachable through the BetterFans Link SDK. ## Issue an SDK key Open **API keys** in your dashboard and create an **SDK key** — your BetterFans Link API key. You can hand it to an integration, rotate it, or revoke it at any time. When you create a key, you choose its scope: - **Organization-wide** — the key can act as any creator your organization owns. - **Specific creators** — the key is restricted to the accounts you select. The full secret is shown **once**, at creation. Copy it then; if you lose it, rotate the key to issue a new one. Revoking a key stops it authorizing requests immediately. Your dashboard also shows per-key request totals and last-used time so you can keep an eye on usage. This SDK key is your **API key** — it authenticates every HTTP request. To connect the realtime [EventBus WebSocket](/docs/websocket), you also pass a **WS token**, a separate derived credential available in your dashboard. > Never commit your SDK key to version control. Use environment variables or your platform's secrets management, and rotate it if it is ever exposed. For custom or enterprise needs, message us on [Telegram](https://t.me/ofmanagercom). ## Requirements - **Runtime:** modern Node.js, Bun, Deno, or Cloudflare Workers - **TypeScript:** 5.0+ recommended (for template literal type inference on route strings) - **Module system:** ESM only - **Dependencies:** Zero runtime dependencies --- # Quickstart Make your first OnlyFans API call in under 2 minutes with the BetterFans Link SDK — the infrastructure behind OFManager. Fetch profiles, list chats, send messages, and listen to real-time events. This guide walks you through creating a client, fetching your profile, listing recent chats, and sending a message — all with full type safety. ## Prerequisites - You have [API access](/getting-started/installation) and the SDK installed - Your `BFL_API_KEY` is in your environment - You know the OnlyFans user ID of the account you're operating on ## Fetch your profile Every request targets a specific OnlyFans account. Use `.for()` to scope the client to an account ID, then call any route: ```ts title="profile.ts" import { OfApiClient } from "@betterfans/link-sdk" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, }) const account = client.for("123456789") const [error, me] = await account.request("GET /users/me", {}) if (error) { console.error(error.code, error.message) process.exit(1) } console.log(me.name, me.username) // → "Jane Doe" "janedoe" ``` The `me` object is fully typed as `UserFull` — your editor autocompletes every field. ## List recent chats Pass `query` parameters to control the results: ```ts title="chats.ts" const [error, chats] = await account.request("GET /chats", { query: { limit: 10, offset: 0, order: "recent", skip_users: "all" }, }) if (!error) { for (const chat of chats.list) { const user = chat.withUser console.log(`${user.name}: ${chat.lastMessage?.text ?? "(media)"}`) } } ``` ## Send a message Write operations use `POST`, `PUT`, or `DELETE` routes. The SDK enforces the correct body shape at compile time: ```ts title="send-message.ts" import { markdownToHtml } from "@betterfans/link-sdk/utils" const [error, message] = await account.request( "POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text: markdownToHtml("Hey! Thanks for subscribing"), }, }, ) if (!error) { console.log("Sent:", message.id) } ``` ## Listen for real-time events Connect to the EventBus to receive events like new messages, tips, and subscriber activity: ```ts title="events.ts" import { OfWsClient } from "@betterfans/link-sdk" const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message", "new_subscriber", "tip"], }) ws.onEnvelope("chat_message", (ctx) => { console.log(`New message from account ${ctx.accountId}`) }) ws.onEnvelope("new_subscriber", (ctx) => { console.log(`New subscriber: ${ctx.payload.fanId}`) }) ws.connect() ``` ## Next steps --- # OnlyFans Chat Bot in 50 Lines Build an OnlyFans chatbot that auto-replies across multiple creator accounts using the BetterFans Link SDK — the OnlyFans API, the infrastructure behind OFManager. This tutorial demonstrates how to build a fully functional, multi-account OnlyFans chat bot in under 50 lines of code using the BetterFans Link SDK — the only developer API for the OnlyFans platform, and the infrastructure behind [OFManager](https://ofmanager.com), the leading OnlyFans agency management platform. Instead of setting up individual webhooks or polling 50 different accounts, we will use the BetterFans Link SDK's single WebSocket connection that receives messages for every creator your agency manages, and automatically replies to new fans. ## The Complete Code Here is the entire application. Copy and paste this into a single file (e.g., `bot.ts`). ```ts title="bot.ts" import { OfApiClient, OfWsClient } from "@betterfans/link-sdk" // 1. Initialize the core API client const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, }) // 2. Initialize the EventBus to listen to all managed accounts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message"], // We only care about messages }) // 3. Set up the event listener ws.onEnvelope("chat_message", async (ctx) => { if (!ctx.accountId) return const msg = ctx.payload.api2_chat_message const fanId = msg.fromUser?.id // Ignore messages sent by the creator themselves if (!fanId || fanId.toString() === ctx.accountId) return console.log(`[Account ${ctx.accountId}] New message from Fan ${fanId}: ${msg.text}`) // 4. Scope an API client specifically to the account that received the message const account = client.for(ctx.accountId) // 5. Check if we've already replied to this user to avoid spam loops // (In a real app, you'd check a database here) const [historyErr, history] = await account.request("GET /chats/:id/messages", { pathParams: { id: fanId.toString() }, query: { limit: 5, order: "desc", skip_users: "all" } }) if (!historyErr && history.list.some(m => m.fromUser.id.toString() === ctx.accountId)) { console.log("Already replied to this fan. Skipping.") return } // 6. Send the automated reply const [sendErr, response] = await account.request("POST /chats/:id/messages", { pathParams: { id: fanId.toString() }, body: { text: "Hey! This is an automated welcome message. Thanks for reaching out!", }, }) if (sendErr) { console.error(`Failed to send message: ${sendErr.message}`) } else { console.log(`Reply sent successfully! Message ID: ${response.id}`) } }) // 7. Connect to the WebSocket console.log("Starting multi-account chat bot...") ws.connect() ``` ## Why this is powerful Building a reliable multi-tenant bot from scratch is weeks of work — connection management, event routing, account scoping, media staging. With the **BetterFans Link SDK** — the only OnlyFans API — that entire proprietary infrastructure layer is handled for you out of the box. You just write your business logic. --- # Core Capabilities (Plugins) OnlyFans API media uploads, realtime event counts, and notifications — the infrastructure behind OFManager that saves weeks of reverse-engineering. Extend the BetterFans Link SDK with plugins. The BetterFans Link SDK doesn't just wrap OnlyFans API HTTP calls; we collapse entire infrastructure layers into single methods through our **Core Capabilities** (exposed as `plugins`). These capabilities add higher-level infrastructure abstractions on top of the core HTTP and WebSocket clients. They're initialized when you create the client and accessed through the `plugin` property. ```ts import { OfApiClient } from "@betterfans/link-sdk" import { media } from "@betterfans/link-sdk/plugins/media" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, plugins: [media()], }) const account = client.for("123456789") const result = await account.plugin.media.upload({ source: "https://cdn.example/photo.jpg", }) ``` ## Available plugins ## How plugins work Plugins receive a context object with access to HTTP requests and WebSocket events. When you call `.for(accountId)`, the scoped client automatically binds plugin methods to that account — no manual ID passing needed. ```ts // Without scoping — accountId is the first argument await client.plugin.media.upload("123456789", { source }) // With scoping — accountId is bound automatically const account = client.for("123456789") await account.plugin.media.upload({ source }) ``` --- # Media Upload photos and videos to OnlyFans programmatically via the BetterFans Link SDK — the infrastructure behind OFManager. One call per upload, deduplication built in, ready to attach to messages and posts. Attach a photo or video to a message or post in a single call. You pass in a source, you get back a reference. The SDK handles deduplication and processing end-to-end. ## Setup ```ts import { OfApiClient } from "@betterfans/link-sdk" import { media } from "@betterfans/link-sdk/plugins/media" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, plugins: [media()], }) const account = client.for("123456789") ``` ## Upload and attach ```ts const result = await account.plugin.media.upload({ source: "https://cdn.example/photo.jpg", }) await account.request("POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text: "here you go", mediaFiles: result.kind === "existing" ? [result.mediaId] : [result.ref], }, }) ``` `source` accepts any of: - a URL string (fetched on your behalf), - a `Blob`, - an `ArrayBuffer` or `Uint8Array`. The result discriminates on `kind`: - `existing` — a byte-identical file already lived in the account's library; `mediaId` is ready to attach. - `new` — a freshly-processed upload; attach the whole `ref` object as a single `mediaFiles` entry. ## Mixed attachments `mediaFiles` accepts a mix of both shapes in one request: ```ts const a = await account.plugin.media.upload({ source: videoUrl }) const b = await account.plugin.media.upload({ source: photoUrl, avoidWatermark: true }) await account.request("POST /posts", { body: { text: "new drop", mediaFiles: [ ...(a.kind === "existing" ? [a.mediaId] : [a.ref]), ...(b.kind === "existing" ? [b.mediaId] : [b.ref]), 11111, // existing vault id you already know about ], price: 15, }, }) ``` ## Options ## Result ## Refreshing cached account state The plugin caches a small amount of per-account state to keep repeat uploads fast. If an account's profile settings change out of band and you want the next upload to pick them up immediately rather than waiting for the internal TTL: ```ts client.plugin.media.invalidateCache("123456789") ``` --- # Realtime Aggregate chat counts and notification events from the WebSocket EventBus. The realtime plugins give you computed state from the EventBus — per-account chat counts and deduplicated notification events — without manually parsing raw WebSocket frames. ## Realtime counts Maintains an in-memory snapshot of chat and notification counts per account, updated live from WebSocket events. ```ts import { OfApiClient } from "@betterfans/link-sdk" import { realtimeCounts } from "@betterfans/link-sdk/plugins/realtime-counts" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, plugins: [realtimeCounts()], }) const counts = client.plugin.realtimeCounts ``` ### Reading counts ```ts // Get counts for a specific account const accountCounts = counts.getAccountCounts("123456789") if (accountCounts) { console.log("Chat messages:", accountCounts.chat?.chatMessages) console.log("Priority chats:", accountCounts.chat?.countPriorityChat) console.log("Notifications:", accountCounts.notifications?.notificationCountBreakdown) } // Get the full snapshot across all accounts const snapshot = counts.getSnapshot() console.log("Initialized:", snapshot.initialized) for (const [accountId, state] of snapshot.accounts) { console.log(accountId, state.chat?.chatMessages) } ``` ### Subscribing to changes React to count updates as they arrive: ```ts const unsubscribe = counts.subscribe(() => { const snapshot = counts.getSnapshot() // re-render your UI, update a badge, etc. }) // later unsubscribe() ``` ### API ## Notification events Surfaces engagement-critical events — tips, PPV purchases, new and returning subscribers — as a deduplicated stream with a 5-second dedup window. ```ts import { OfApiClient } from "@betterfans/link-sdk" import { notificationEvents } from "@betterfans/link-sdk/plugins/notification-events" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, plugins: [notificationEvents()], }) const notifications = client.plugin.notificationEvents ``` ### Event types | Event | Triggered when | |-------|---------------| | `tip` | A tip is received | | `ppv_purchase` | A pay-per-view message is purchased | | `new_subscriber` | A new user subscribes | | `returning_subscriber` | A previously expired subscriber resubscribes | | `fan_message` | A fan sends a message | ### Subscribing to events ```ts const unsubscribe = notifications.subscribe(() => { const { latestEvent, eventCount } = notifications.getSnapshot() if (latestEvent) { console.log( `[${latestEvent.type}]`, `Account: ${latestEvent.data.accountId}`, `Amount: ${latestEvent.data.amount}`, ) } }) ``` ### Clearing ```ts // Clear the latest event (e.g. after displaying a toast) notifications.clearEvent() ``` ### API --- # HTTP Client Create and configure the OfApiClient for making typed OnlyFans API requests via the BetterFans Link SDK — the infrastructure behind OFManager. Every route, parameter, and response is fully typed at compile time. The `OfApiClient` is the primary interface for making typed HTTP requests to the OnlyFans API. Every request is fully typed — routes, parameters, bodies, and responses all have compile-time guarantees. ```ts import { OfApiClient } from "@betterfans/link-sdk" const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, }) ``` ## Configuration The client accepts the following options: ## How requests work You write a route string like `"GET /chats"` and the SDK handles everything else — constructing the request, authenticating, and returning a typed response. No manual URL building, header management, or response parsing. ```ts const [error, chats] = await account.request("GET /chats", { query: { limit: 20 }, }) // chats is fully typed — autocomplete works on every field ``` ## Next steps --- # Making requests Use the typed request methods to call any OnlyFans API route. The SDK provides two patterns for making requests: a **tuple pattern** that returns `[error, data]` for explicit error handling, and a **throw pattern** that throws on failure. ## The tuple pattern: `request()` `request()` returns a tuple of `[error, null]` or `[null, data]`. This makes error handling explicit without try/catch: ```ts const account = client.for("123456789") const [error, data] = await account.request("GET /users/me", {}) if (error) { console.error(error.code, error.status, error.message) } else { console.log(data.name) // fully typed as UserFull } ``` The first argument is always a **route string** in the format `"METHOD /path"`. TypeScript narrows the parameters and response based on this string. ## The throw pattern: `fetch()` If you prefer exceptions, `fetch()` has the same signature but throws an `OfApiError` on failure: ```ts try { const me = await client.fetch("GET /users/me", { auth: { onlyfansUserId: "123456789" }, }) console.log(me.name) } catch (error) { if (error instanceof OfApiError) { console.error(error.code, error.status) } } ``` ## Verb shortcuts For ergonomics, the client exposes `.GET()`, `.POST()`, `.PUT()`, and `.DELETE()` methods. These are sugar over `request()` with the verb extracted from the path: ```ts const [error, chats] = await client.GET("/chats", { auth: { onlyfansUserId: "123456789" }, query: { limit: 20 }, }) ``` ```ts const [error, message] = await client.POST("/chats/:id/messages", { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "98765" }, body: { text: markdownToHtml("Hello!") }, }) ``` ```ts const [error, updated] = await client.PUT("/messages/queue/:id", { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "55555" }, body: { text: markdownToHtml("Updated message content") }, }) ``` ```ts const [error] = await client.DELETE("/messages/queue/:id", { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "55555" }, }) ``` ## Request options Every request accepts these options: ## Route string format Routes follow the pattern `"VERB /path"` exactly as they appear in the OnlyFans API. Path parameters use `:name` syntax: ```ts // No parameters "GET /users/me" // Path parameter "GET /users/:id" "DELETE /messages/:id" // Multiple path parameters "POST /lists/:id/users/:id" ``` TypeScript infers the required `pathParams`, `query`, and `body` from the route string. If you misspell a route, you get a compile error — not a runtime 404. ## Type inference You can extract input and output types for any route using the `ApiRoutes` interface: ```ts import type { ApiRoutes } from "@betterfans/link-sdk" type ChatsQuery = ApiRoutes["GET /chats"]["query"] // { limit?: number; offset?: number; order?: string; ... } type ChatsResponse = ApiRoutes["GET /chats"]["response"] // { list: ChatListItem[]; hasMore: boolean; ... } type SendBody = ApiRoutes["POST /chats/:id/messages"]["body"] // { text?: string; mediaFiles?: ...; price?: number; ... } ``` This is useful for building typed wrappers or passing data between functions without losing type information. --- # Scoped clients Bind a client to a specific OnlyFans account to simplify multi-account workflows. Most agencies manage multiple OnlyFans accounts. Instead of passing `auth` on every request, create a **scoped client** that's bound to a specific account. ## Create a scoped client Call `.for(accountId)` on the main client: ```ts const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, }) const alice = client.for("111111111") const bob = client.for("222222222") ``` Now `alice` and `bob` automatically authenticate as the correct account on every request. No need to pass `auth`: ```ts // Without scoped client — auth required every time const [error, me] = await client.request("GET /users/me", { auth: { onlyfansUserId: "111111111" }, }) // With scoped client — auth is automatic const [error, me] = await alice.request("GET /users/me", {}) ``` ## Scoped client API A `ScopedClient` exposes the same `request()` method as the main client. The `auth` field becomes optional — if you omit it, the scoped account ID is used. If you pass it, it overrides the default. ```ts // Uses the scoped account const [err, chats] = await alice.request("GET /chats", { query: { limit: 20 }, }) // Override with a different account (rare, but supported) const [err, chats] = await alice.request("GET /chats", { auth: { onlyfansUserId: "333333333" }, query: { limit: 20 }, }) ``` ## Multi-account patterns A common pattern is to iterate over your managed accounts and create scoped clients for each: ```ts const managedAccounts = ["111111111", "222222222", "333333333"] for (const accountId of managedAccounts) { const account = client.for(accountId) const [error, me] = await account.request( "GET /users/me", {}, ) if (!error) { console.log(`Account ${accountId}: ${me.subscribesCount} subs`) } } ``` ## When to scope > Use scoped clients when you're making multiple requests for the same account. For one-off requests, passing `auth` inline is fine. | Pattern | Best for | |---------|----------| | `client.for(id)` | Processing a single account's data in a function or job | | Inline `auth` | Ad-hoc requests where the account varies per call | | Loop + `.for()` | Batch operations across all managed accounts | --- # Batch requests Send multiple API requests in a single call with typed results. The SDK supports server-side request batching. Group related API calls into a single batch and get back typed results for each. This reduces latency and connection overhead. ## `batch()` — send and await all Pass an array of route/options tuples. The SDK sends them to the batch endpoint and returns a typed tuple for each: ```ts const [chatResult, profileResult, statsResult] = await client.batch( { route: "GET /chats", options: { auth: { onlyfansUserId: "123456789" }, query: { limit: 10 }, }, }, { route: "GET /users/me", options: { auth: { onlyfansUserId: "123456789" } }, }, { route: "GET /users/me/stats/overview", options: { auth: { onlyfansUserId: "123456789" } }, }, ) // Each result is typed as [OfApiError, null] | [null, RouteResponse] const [chatErr, chats] = chatResult const [profileErr, profile] = profileResult const [statsErr, stats] = statsResult ``` Each entry in the result array is independently typed based on the route you specified. ## `batchStream()` — process results as they arrive `batchStream()` returns results as an async generator. Results arrive in the order they complete, not the order you sent them: ```ts for await (const { index, result } of client.batchStream( { route: "GET /users/:id", options: { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "111" }, }, }, { route: "GET /users/:id", options: { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "222" }, }, }, )) { const [error, user] = result if (!error) { console.log(`Result ${index}: ${user.name}`) } } ``` The `index` field tells you which entry in your original batch this result corresponds to. ## `batchWith()` — control chunking and staggering `batchWith()` lets you control how many requests are sent per batch call and the delay between chunks: ```ts const results = await client.batchWith( { maxPerCall: 25, staggerMs: 500 }, ...userIds.map((id) => ({ route: "GET /users/:id" as const, options: { auth: { onlyfansUserId: "123456789" }, pathParams: { id }, }, })), ) ``` ## `batchUsers()` — same route across accounts A common pattern is running the same request for each of your managed accounts. The `batchUsers()` method simplifies this: ```ts const accountIds = ["111", "222", "333", "444", "555"] const results = await client.batchUsers( "GET /users/:id", accountIds, (auth) => ({ auth, pathParams: { id: auth.onlyfansUserId }, }), ) // results is Record for (const [accountId, [error, user]] of Object.entries(results)) { if (!error) { console.log(`${accountId}: ${user.name}`) } } ``` ## Per-entry retries Each batch entry can specify its own `maxRetries`. The server retries failed entries independently: ```ts const [result] = await client.batch({ route: "GET /users/:id", options: { auth: { onlyfansUserId: "123456789" }, pathParams: { id: "111" }, }, maxRetries: 5, }) ``` ## Limits - Up to **200 requests** per batch call by default; larger arrays are automatically split into multiple calls (tune with `batchWith()`'s `maxPerCall`) - All requests in a batch share the same API key - Each request can target a different account ID --- # Error handling Handle errors, configure retries, and understand error codes. The SDK uses a tuple-based error pattern inspired by Go's error handling. Every `request()` call returns `[error, null]` on failure or `[null, data]` on success — no try/catch needed for normal error flow. ## The error tuple ```ts const [error, data] = await account.request("GET /users/me", {}) if (error) { // error is OfApiError console.error(error.code) // "RATE_LIMITED" | "UNAUTHORIZED" | ... console.error(error.status) // HTTP status code console.error(error.message) // Human-readable description return } // data is fully typed — no null check needed console.log(data.name) ``` This pattern makes it impossible to accidentally use the response without checking for errors first. ## Error codes The `code` field on `OfApiError` is one of these values: | Code | Status | Meaning | |------|--------|---------| | `UNAUTHORIZED` | 401 | Invalid or missing API key | | `FORBIDDEN` | 403 | Valid key but insufficient access | | `NOT_FOUND` | 404 | Route or resource doesn't exist | | `RATE_LIMITED` | 429 | Too many requests — retried automatically | | `API_ERROR` | other | Upstream error (including 5xx — retried automatically) | | `NETWORK` | — | Connection failed — retried automatically | | `PARSE_ERROR` | — | Response body could not be parsed | ## Automatic retries GET requests are retried automatically on `429`, `5xx`, and network errors. The SDK backs off between attempts (scaling with the attempt number) with added jitter. Default retry configuration: Override retries per-client or per-request: ```ts const client = new OfApiClient({ apiKey: process.env.BFL_API_KEY, retry: { maxAttempts: 5, backoffMs: 2000 }, }) ``` ```ts const [error, data] = await account.request( "GET /chats", { query: { limit: 20 } }, { retry: 1 }, ) ``` > Only GET requests are retried by default. POST, PUT, and DELETE requests are **not** retried to avoid duplicate side effects. ## Using `fetch()` with try/catch If you prefer the throw pattern, use `fetch()` instead of `request()`: ```ts import { OfApiError } from "@betterfans/link-sdk" try { const me = await account.fetch("GET /users/me", {}) console.log(me.name) } catch (error) { if (error instanceof OfApiError) { switch (error.code) { case "RATE_LIMITED": console.log("Rate limited, try again later") break case "UNAUTHORIZED": console.log("Check your API key") break default: console.error("Request failed:", error.message) } } } ``` ## Checking error types `OfApiError` is exported from the SDK for `instanceof` checks: ```ts import { OfApiError } from "@betterfans/link-sdk" const [error, data] = await account.request("GET /users/me", {}) if (error) { if (error.status === 429) { // Rate limited — back off } if (error.code === "NOT_FOUND") { // Resource doesn't exist } } ``` --- # WebSocket EventBus Subscribe to real-time OnlyFans events — new messages, tips, subscribers, online status — across all managed accounts over a single WebSocket connection. Part of the BetterFans Link SDK, the infrastructure behind OFManager. The BetterFans Link SDK EventBus gives you a real-time stream of OnlyFans events across all your managed accounts. New messages, tips, subscriptions, online status changes — they all arrive as typed events over a single WebSocket connection. ```ts import { OfWsClient } from "@betterfans/link-sdk" const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message", "tip", "new_subscriber"], }) ws.onEnvelope("chat_message", (ctx) => { console.log(`Message from account ${ctx.accountId}`) }) ws.connect() ``` ## Authentication The EventBus uses your **API key** and a **WS token** for authentication. Both are available on the API keys page in your BetterFans Link dashboard. The WS token is a derived credential specifically for WebSocket connections — it's separate from your API key for security isolation. ## Subscribing to events Use the `subscribe` option to specify which event types you want to receive. If omitted or empty, you receive all events. ```ts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message", "tip", "new_subscriber"], }) ``` ```ts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, // omit subscribe or pass an empty array to receive everything }) ``` ## Filtering by account If you only care about events from specific accounts, use the `filter` option: ```ts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, subscribe: ["chat_message"], filter: { accountIds: ["111111111", "222222222"], }, }) ``` Events from other accounts are silently dropped server-side. ## Listening for events Register handlers with `.onEnvelope()` for specific events. The handler receives a dispatch context carrying the originating `accountId` and the typed `payload`: ```ts ws.onEnvelope("chat_message", (ctx) => { console.log(ctx.accountId, ctx.payload) }) ws.onEnvelope("new_subscriber", (ctx) => { console.log(`New sub on ${ctx.accountId}: fan ${ctx.payload.fanId}`) }) ws.onEnvelope("tip", (ctx) => { console.log(`Tip: ${ctx.payload.amountCents}¢ on ${ctx.accountId}`) }) ``` Or use `.onAnyEnvelope()` to receive all events: ```ts ws.onAnyEnvelope((ctx) => { console.log(`[${ctx.type}]`, ctx.payload) }) ``` ## Connection lifecycle ```ts // Connect ws.connect() // Check state console.log(ws.state) // "connecting" | "connected" | "reconnecting" | "disconnected" console.log(ws.connected) // boolean // Disconnect ws.disconnect() ``` ## Reconnection The client reconnects automatically on connection drops. Configure the reconnection behavior: ## State changes Monitor connection state transitions: ```ts const ws = new OfWsClient({ apiKey: process.env.BFL_API_KEY, wsToken: process.env.BFL_WS_TOKEN, onStateChange: (state) => { console.log(`Connection state: ${state}`) // "connecting" | "connected" | "reconnecting" | "disconnected" }, onError: (event, payload, error) => { console.error(`Handler error for "${event}":`, error) }, }) ``` ## Removing handlers ```ts // Remove a specific handler ws.offEnvelope("chat_message") ``` --- # Event reference Complete reference of all EventBus event types and their payloads. The EventBus emits typed events as they occur across your managed accounts. Register handlers with `.onEnvelope(type, handler)`; each handler receives a dispatch context whose `accountId` identifies the account the event originated from, and whose `payload` carries the typed event body. ## Event types | Event | Description | |-------|-------------| | `chat_message` | A new chat message was received or sent | | `system_message` | A system-generated message (auto-replies, welcome messages) | | `online` | An account's online status changed | | `counts` | Unread counts updated (chats, notifications) | | `typing` | A user started or stopped typing in a chat | | `chat_message_like` | A chat message was liked | | `chat_message_unlike` | A chat message was unliked | | `tip` | A tip was received | | `new_subscriber` | A new user subscribed | | `returning_subscriber` | A previously expired subscriber resubscribed | | `ppv_purchase` | A pay-per-view message was purchased | | `ppv_sent` | A pay-per-view message was sent | ## Event payloads Every event handler receives a typed payload. Here are the most common events and their key fields. ### `chat_message` Fired when a message is received or sent in any chat. ```ts ws.onEnvelope("chat_message", (ctx) => { ctx.accountId // string | null — which account it belongs to ctx.payload.api2_chat_message // the raw OF chat message (text, fromUser, media, ...) }) ``` ### `tip` Fired when a tip is received on any account. ```ts ws.onEnvelope("tip", (ctx) => { ctx.payload.accountId // string — which account received it ctx.payload.fanId // number — who tipped ctx.payload.amountCents // number — tip amount in cents ctx.payload.payload.fanUsername // string — tipper's username (optional) }) ``` ### `new_subscriber` Fired when someone subscribes to one of your accounts. ```ts ws.onEnvelope("new_subscriber", (ctx) => { ctx.payload.accountId // string — which account got a subscriber ctx.payload.fanId // number — new subscriber's user ID ctx.payload.amountCents // number | null — subscription price in cents }) ``` ### `returning_subscriber` Fired when a previously expired subscriber resubscribes. ```ts ws.onEnvelope("returning_subscriber", (ctx) => { ctx.payload.accountId // string ctx.payload.fanId // number }) ``` ### `ppv_purchase` Fired when a user purchases a pay-per-view message. ```ts ws.onEnvelope("ppv_purchase", (ctx) => { ctx.payload.accountId // string ctx.payload.chatMessageId // number — the purchased message ctx.payload.fanId // number — who purchased ctx.payload.amountCents // number | null — amount in cents }) ``` ### `online` Fired when an account's online status changes. ```ts ws.onEnvelope("online", (ctx) => { ctx.accountId // string | null — which account this belongs to ctx.payload.online // array — online status entries }) ``` ### `typing` Fired when a user starts or stops typing. ```ts ws.onEnvelope("typing", (ctx) => { ctx.accountId // string | null — which account this belongs to ctx.payload.typing // the raw OF typing frame }) ``` ### `counts` Fired when unread counts change. ```ts ws.onEnvelope("counts", (ctx) => { ctx.accountId // string | null — which account this belongs to ctx.payload.chat_messages // number — unread chat messages ctx.payload.count_priority_chat // number — priority chats ctx.payload.unread_tips // number — unread tips }) ``` ## TypeScript types All event types are exported for use in your own type definitions: ```ts import type { BusEventMap, BusEventType, BusChatMessageEvent, BusOnlineEvent, BusEventEnvelope, } from "@betterfans/link-sdk" type MyHandler = (event: BusChatMessageEvent) => void ``` The `BusEventMap` type maps event names to their payload types, so you can build generic event handling: ```ts function handleEvent( type: T, handler: (event: BusEventMap[T]) => void, ) { ws.onEnvelope(type, (ctx) => handler(ctx.payload as BusEventMap[T])) } ``` --- # OnlyFans API Reference Complete OnlyFans API route reference — chats, messages, posts, users, subscriptions, vault, stories, earnings. Every route is fully typed in the BetterFans Link SDK, the infrastructure behind OFManager. Every route on the OnlyFans API is available through the SDK with full type safety. Route strings, query parameters, request bodies, and responses are all typed at compile time. ## How routes work Routes follow the `"VERB /path"` format. TypeScript uses the route string to infer the exact parameter and response types: ```ts // TypeScript knows this route returns a UserFull const [err, me] = await account.request("GET /users/me", {}) me.name // ✓ autocomplete works // TypeScript knows this route needs pathParams.id and a body const [err, msg] = await account.request("POST /chats/:id/messages", { pathParams: { id: "12345" }, body: { text: markdownToHtml("Hello!") }, }) ``` If you mistype a route, TypeScript catches it at compile time: ```ts // ✗ Type error — no such route await account.request("GET /chat", {}) ``` ## Route domains ## Extracting types You can extract the input and output types for any route: ```ts import type { ApiRoutes } from "@betterfans/link-sdk" // Response type for a specific route type ChatList = ApiRoutes["GET /chats"]["response"] // Query parameters for a route type ChatQuery = ApiRoutes["GET /chats"]["query"] // Path parameters for a route (when applicable) type MessagePath = ApiRoutes["POST /chats/:id/messages"]["pathParams"] // { id: string } // Request body for a write route type SendBody = ApiRoutes["POST /chats/:id/messages"]["body"] ``` --- # Chats List OnlyFans conversations, search chat history, retrieve messages, and manage read status programmatically via the BetterFans Link SDK, the infrastructure behind OFManager. Chat routes let you list and search conversations, read chat details, and manage read status for an account. ## List chats Retrieve recent chats, ordered by most recent activity. ```ts const [error, chats] = await account.request("GET /chats", { query: { limit: 20, offset: 0, order: "recent", }, }) if (!error) { for (const chat of chats.list) { console.log(chat.withUser.name, chat.lastMessage?.text) } } ``` ## Search chats Search through chat history by keyword: ```ts const [error, results] = await account.request( "GET /chats/:id/messages/search", { pathParams: { id: "98765" }, query: { query: "payment" }, }, ) ``` ## Get chat messages Retrieve messages within a specific chat: ```ts const [error, messages] = await account.request( "GET /chats/:id/messages", { pathParams: { id: "98765" }, query: { limit: 20, order: "desc" }, }, ) if (!error) { for (const msg of messages.list) { console.log(msg.fromUser.name, msg.text) } } ``` ## Mark chat as read ```ts const [error] = await account.request( "POST /chats/:id/mark-as-read", { pathParams: { id: "98765" }, }, ) ``` --- # Messages Send OnlyFans messages programmatically — DMs, mass messages, PPV (pay-per-view), media attachments, and message queue management via the BetterFans Link SDK, the infrastructure behind OFManager. Message routes handle sending DMs, managing the mass-message queue, liking/unliking messages, and deleting messages. > The API expects message and post text as HTML, not plaintext. The SDK exports a `markdownToHtml` utility that converts markdown to the format the API requires. ```ts import { markdownToHtml } from "@betterfans/link-sdk/utils" markdownToHtml("**Bold** and *italic*") // →

Bold and italic

``` ## Send a message Send a direct message to a user within a chat: ```ts import { markdownToHtml } from "@betterfans/link-sdk/utils" const [error, message] = await account.request( "POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text: markdownToHtml("Thanks for subscribing!"), }, }, ) if (!error) { console.log("Sent message:", message.id) } ``` ### Send with media Attach media to a message by including media IDs: ```ts const [error, message] = await account.request( "POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text: markdownToHtml("Check this out"), mediaFiles: [mediaId1, mediaId2], }, }, ) ``` ### Send a pay-per-view message Set a price to create a PPV message: ```ts const [error, message] = await account.request( "POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text: markdownToHtml("Exclusive content"), mediaFiles: [mediaId], price: 15, }, }, ) ``` ## Mass-message queue The queue system lets you send messages to many users at once. The server processes them asynchronously. ### Create a queued message ```ts const [error, queue] = await account.request( "POST /messages/queue", { body: { text: markdownToHtml("New content dropping tonight"), mediaFiles: [mediaId], userLists: [listId], }, }, ) if (!error) { console.log("Queue ID:", queue.id, "Success:", queue.success) } ``` ### Check queue status ```ts const [error, queues] = await account.request( "GET /messages/queue", {}, ) if (!error) { for (const q of queues) { console.log(q.id, q.isDone, `${q.pending}/${q.total}`) } } ``` ### Update a queued message Modify a queued message before it's fully sent: ```ts const [error] = await account.request( "PUT /messages/queue/:id", { pathParams: { id: "12345" }, body: { text: markdownToHtml("Updated message text") }, }, ) ``` ### Cancel a queued message ```ts const [error] = await account.request( "DELETE /messages/queue/:id", { pathParams: { id: "12345" }, }, ) ``` ## Message interactions ### Like a message ```ts const [error] = await account.request( "POST /messages/:id/like", { pathParams: { id: "55555" } }, ) ``` ### Unlike a message ```ts const [error] = await account.request( "DELETE /messages/:id/like", { pathParams: { id: "55555" } }, ) ``` ### Delete a message ```ts const [error] = await account.request( "DELETE /messages/:id", { pathParams: { id: "55555" } }, ) ``` --- # Posts Query feed posts and their engagement data. Post routes let you list and read feed posts, query engagement charts and top-performing content, and inspect scheduled posts. ## List posts Retrieve posts for an account: ```ts const [error, posts] = await account.request("GET /posts", { query: { limit: 20 }, }) if (!error) { for (const post of posts.list) { console.log(post.text, `👍 ${post.favoritesCount} ❤️ ${post.tipsAmount}`) } } ``` ## Get a single post ```ts const [error, post] = await account.request("GET /posts/:id", { pathParams: { id: "11111" }, }) if (!error) { console.log(post.text, post.media) } ``` ## Post analytics ### Engagement chart ```ts const [error, chart] = await account.request("GET /posts/chart", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }) ``` ### Top-performing posts ```ts const [error, top] = await account.request("GET /posts/top", { query: { startDate: "2026-01-01", endDate: "2026-03-31", by: "tips", offset: 0, skip_users: "all", }, }) if (!error) { for (const post of top.items) { console.log(post.text, `$${post.tipsAmount}`) } } ``` ## Scheduled posts ### List scheduled posts ```ts const [error, schedules] = await account.request( "GET /schedules/later/post", { query: { limit: 20 } }, ) ``` ### Collect post stats Trigger a stats collection for specific posts: ```ts const [error] = await account.request("POST /posts/stats-collect", { body: { actions: [ { action: "view", postId: "11111", eventTime: "2026-03-31T12:00:00Z", duration: 0, }, ], }, }) ``` --- # Users Fetch profiles, account stats, notifications, settings, and manage blocks. User routes are the largest group in the API, covering profiles, stats, notifications, settings, and user relationships. ## Get your profile ```ts const [error, me] = await account.request("GET /users/me", {}) if (!error) { console.log(me.name, me.username) console.log(`Subscribers: ${me.subscribersCount}`) console.log(`Posts: ${me.postsCount}`) } ``` The response is typed as `UserFull` — the most complete user representation with all profile fields, stats, and settings. ## Get another user's profile ```ts const [error, user] = await account.request("GET /users/:id", { pathParams: { id: "987654321" }, }) if (!error) { console.log(user.name, user.username) console.log(`Subscribed: ${!!user.subscribedOnData}`) } ``` ## Account stats ```ts const [error, stats] = await account.request( "GET /users/me/stats/overview", {}, ) ``` ## Notifications Fetch notifications with optional type narrowing: ```ts const [error, notifications] = await account.request( "GET /users/notifications", { query: { limit: 20 } }, ) ``` ```ts const [error, notifications] = await account.request( "GET /users/notifications", { query: { limit: 20, type: "subscribed" } }, ) ``` ```ts const [error, notifications] = await account.request( "GET /users/notifications", { query: { limit: 20, type: "tip" } }, ) ``` ### Mark notifications as read ```ts await account.request("POST /users/notifications/read", {}) ``` ### Notification counts ```ts const [error, counts] = await account.request( "GET /users/notifications/count", {}, ) if (!error) { console.log(`Unread: ${counts.all}`) } ``` ## Settings ```ts const [error, settings] = await account.request( "GET /users/me/settings", {}, ) if (!error) { console.log("Private profile:", settings.isPrivate) } ``` ## Blocking ### List blocked users ```ts const [error, blocked] = await account.request( "GET /users/blocked", { query: { limit: 20, offset: 0, format: "infinite" } }, ) ``` ## Profile visits Log a profile visit (used for analytics): ```ts await account.request("POST /users/profile/visit", {}) await account.request("POST /users/profile/view", {}) ``` --- # Subscriptions Manage OnlyFans subscribers programmatically — list active subscribers, view subscription history, handle free trials, and track subscriber counts via the BetterFans Link SDK, the infrastructure behind OFManager. Subscription routes let you query your subscriber lists, manage active subscriptions, view history, and handle free trials. ## List subscribers Get a list of subscribers to your account: ```ts const [error, subs] = await account.request( "GET /subscriptions/subscribers", { query: { limit: 10, type: "active", }, }, ) if (!error) { for (const sub of subs.list) { console.log(sub.username, sub.subscribedByData?.price) } } ``` ## List subscriptions (accounts you subscribe to) ```ts const [error, subscribes] = await account.request( "GET /subscriptions/subscribes", { query: { limit: 10, type: "active" } }, ) ``` ## Subscriber count Get a quick count of all subscriber categories: ```ts const [error, counts] = await account.request( "GET /subscriptions/count/all", {}, ) if (!error) { console.log(`Active: ${counts.subscribers.active}`) console.log(`Expired: ${counts.subscribers.expired}`) } ``` ## Subscription history View the subscription history for a specific user: ```ts const [error, history] = await account.request( "GET /subscriptions/:id/history", { pathParams: { id: "987654321" } }, ) ``` ## Subscribe / Unsubscribe ### Subscribe to a user ```ts const [error] = await account.request( "POST /users/:id/subscribe", { pathParams: { id: "987654321" } }, ) ``` ### Unsubscribe from a user ```ts const [error] = await account.request( "DELETE /users/:id/subscribe", { pathParams: { id: "987654321" } }, ) ``` ## Trials ### List trial links ```ts const [error, trials] = await account.request("GET /trials", { query: { limit: 20 }, }) ``` ### Trial stats ```ts const [error, stats] = await account.request("GET /trials/stats", {}) ``` ### Trial chart data ```ts const [error, chart] = await account.request("GET /trials/chart", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }) ``` --- # Vault Manage vault lists, media items, and visibility settings. Vault routes give you access to the media vault — organize media into lists, manage visibility, and query media items. ## List vault lists ```ts const [error, lists] = await account.request("GET /vault/lists", { query: { limit: 20 }, }) if (!error) { for (const list of lists.list) { console.log(list.name, `${list.photosCount} photos`) } } ``` ## Create a vault list ```ts const [error, list] = await account.request("POST /vault/lists", { body: { name: "Best content" }, }) if (!error) { console.log("Created list:", list.id) } ``` ## Update a vault list ```ts const [error] = await account.request("PATCH /vault/lists/:id", { pathParams: { id: "12345" }, body: { name: "Renamed list" }, }) ``` ## Delete a vault list ```ts const [error] = await account.request("DELETE /vault/lists/:id", { pathParams: { id: "12345" }, }) ``` ## Add media to a vault list ```ts const [error] = await account.request( "POST /vault/lists/:id/media", { pathParams: { id: "12345" }, body: { mediaIds: [111, 222, 333] }, }, ) ``` ## Remove media from a vault list ```ts const [error] = await account.request( "DELETE /vault/lists/:id/media", { pathParams: { id: "12345" }, body: { mediaIds: [111] }, }, ) ``` ## Toggle media visibility Hide or unhide media items from your profile: ```ts const [error] = await account.request("PUT /vault/media/hidden", { body: { mediaIds: [111, 222] }, }) ``` ## Query vault media ### By type ```ts const [error, types] = await account.request( "GET /vault/media/types", {}, ) ``` ### All media in a list ```ts const [error, media] = await account.request( "GET /vault/media", { query: { list: "12345", limit: 20 }, }, ) ``` --- # Stories Query stories, highlights, charts, and engagement data. Story routes let you access story items, highlights, engagement charts, and top-performing story content. ## List story items ```ts const [error, stories] = await account.request( "GET /stories/items", { query: { limit: 20 } }, ) if (!error) { for (const { story } of Object.values(stories)) { console.log(story.id) } } ``` ## Get highlights for a user ```ts const [error, highlights] = await account.request( "GET /users/:id/stories/highlights", { pathParams: { id: "123456789" } }, ) if (!error) { for (const highlight of highlights.list) { console.log(highlight.title, `${highlight.storiesCount} stories`) } } ``` ## Story analytics ### Engagement chart ```ts const [error, chart] = await account.request( "GET /stories/chart", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }, ) ``` ### Top stories ```ts const [error, top] = await account.request("GET /stories/top", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }) if (!error) { for (const story of top.items) { console.log(story.id, `$${story.tipsAmount} in tips`) } } ``` ### Story map ```ts const [error, map] = await account.request("GET /stories/map", {}) ``` ## Mark a story as watched ```ts const [error] = await account.request( "PUT /stories/:id/watched", { pathParams: { id: "55555" } }, ) ``` --- # Earnings Access OnlyFans revenue data programmatically — earnings charts, payout history, transaction records, referral balances, and campaign analytics via the BetterFans Link SDK, the infrastructure behind OFManager. Earnings routes give you access to revenue breakdowns, payout management, transaction history, and referral data. ## Revenue chart Get a breakdown of earnings by source (subscriptions, tips, messages, posts, streams, referrals): ```ts const [error, chart] = await account.request( "GET /earnings/chart", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }, ) if (!error) { console.log(`Total: $${chart.total?.total}`) console.log(`Subscriptions: $${chart.subscribes?.total}`) console.log(`Tips: $${chart.tips?.total}`) console.log(`Messages: $${chart.chat_messages?.total}`) } ``` ## Payout information ### Payout stats ```ts const [error, stats] = await account.request( "GET /payouts/stats", {}, ) ``` ### Payout history ```ts const [error, payouts] = await account.request( "GET /payouts/requests", {}, ) ``` ### Balance and pending ```ts const [error, balance] = await account.request( "GET /payouts/balances", {}, ) if (!error) { console.log(`Available: $${balance.payoutAvailable}`) console.log(`Pending: $${balance.payoutPending}`) } ``` ### Chargebacks ```ts const [error, chargebacks] = await account.request( "GET /payouts/chargebacks", { query: { limit: 20 } }, ) ``` ## Transactions ```ts const [error, transactions] = await account.request( "GET /payouts/transactions", { query: { limit: 20 } }, ) ``` ## Referrals ### Referral balance ```ts const [error, balance] = await account.request( "GET /payments/referrals/balance", {}, ) ``` ## Campaigns ### List campaigns ```ts const [error, campaigns] = await account.request( "GET /campaigns", { query: { limit: 20 } }, ) ``` ### Campaign chart ```ts const [error, chart] = await account.request( "GET /campaigns/chart", { query: { startDate: "2026-01-01", endDate: "2026-03-31" }, }, ) ``` --- # Types TypeScript types for the OnlyFans API — User, Message, Post, Media, Subscription, and all entity types used across the BetterFans Link SDK, the infrastructure behind OFManager. The SDK ships with comprehensive TypeScript types for the OnlyFans API. Every response field, every query parameter, and every entity shape is typed. All types are importable directly from the SDK: ```ts import type { UserFull, MessageMessage, MediaTop, ApiRoutes, } from "@betterfans/link-sdk" ``` ## User types OnlyFans returns different user shapes depending on the context. The SDK provides specific types for each: | Type | When you get it | Key fields | |------|----------------|------------| | `UserFull` | `GET /users/me` | Full profile with stats, settings, all fields | | `UserExtended` | Subscriber lists, user lookups | Profile + subscription data | | `UserSender` | Message sender context | Name, avatar, username | | `UserThumbnail` | Compact list items | Minimal card data | | `UserMessaging` | Chat and messaging context | Messaging-specific fields | | `UserChatList` | Chat list items | Chat-related user info | | `UserStory` | Story context | Story viewer/creator data | | `UserNotification` | Notification payloads | Notification-specific fields | The `User` union type covers all variants: ```ts import type { User, UserFull, UserExtended } from "@betterfans/link-sdk" ``` ### Common user fields Most user types share these fields from `UserCore`: ```ts interface UserCore { id: UserId name: string username: string avatar: string header: string isVerified: boolean // ... many more } ``` ## Message types | Type | Description | |------|-------------| | `MessageMessage` | A chat message with `responseType: "message"` — text, media, pricing, queue status | | `MessagePost` | A post-shaped payload with `responseType: "post"` — author, media, voting, labels | | `LastMessage` | Lightweight preview shown in chat lists | | `Message` | Union of `MessageMessage \| MessagePost` | ```ts import type { MessageMessage, MessagePost } from "@betterfans/link-sdk" function handleMessage(msg: MessageMessage) { console.log(msg.text, msg.price, msg.media) } ``` ### Key message fields ```ts interface MessageMessage { id: MessageId text: string price: number fromUser: UserSender media: MediaTop[] createdAt: string isOpened: boolean // ... many more } ``` ## Media types Media is view-partitioned — the shape depends on which feed it came from. `MediaCore` is the union of all three. | Type | Description | |------|-------------| | `MediaCore` | Union of `MediaMessages \| MediaTop \| MediaVault` | | `MediaTop` | Media in a profile/feed (top) context | | `MediaMessages` | Media attached to a chat message | | `MediaVault` | Media from the vault | | `MediaFile` | Media file metadata | ```ts import type { MediaTop } from "@betterfans/link-sdk" function displayMedia(media: MediaTop) { console.log(media.type, media.files) } ``` ## Subscription types | Type | Description | |------|-------------| | `SubscribedByData` | Data about who subscribed to you | | `SubscribedOnData` | Data about who you're subscribed to | | `SubscriptionBundle` | Bundle pricing information | | `Trial` | Free trial link data | | `PromoOffer` | Promotional offer details | ## Post types | Type | Description | |------|-------------| | `MessagePost` | The primary post shape (used in feed and responses) | | `PostStream` | Post stats and overview data | ## Story types | Type | Description | |------|-------------| | `StoryHighlights` | Story highlights collection | | `Highlight` | Individual highlight with stories | | `StoryTop` | Top story with engagement stats | ## Stream types | Type | Description | |------|-------------| | `Stream` | Full live room data — room ID, platform, tips, scheduling | ## WebSocket event types | Type | Description | |------|-------------| | `BusEventMap` | Maps event names to payload types | | `BusEventType` | Union of all event name strings | | `BusChatMessageEvent` | Chat message event payload | | `BusOnlineEvent` | Online status change payload | | `BusEventEnvelope` | Raw event envelope from the bus | ## The `ApiRoutes` interface The `ApiRoutes` interface is the type-level registry of every route. Use it to extract types for any endpoint: ```ts import type { ApiRoutes } from "@betterfans/link-sdk" // Extract response type type MyProfile = ApiRoutes["GET /users/me"]["response"] // Extract query params type ChatQuery = ApiRoutes["GET /chats"]["query"] // Extract request body type NewMessage = ApiRoutes["POST /chats/:id/messages"]["body"] // Extract path params type MessagePath = ApiRoutes["DELETE /messages/:id"]["pathParams"] ``` This is the foundation of the SDK's type safety — the route string you pass to `request()` is used as a key into `ApiRoutes` to infer everything else. --- # Utilities Helper functions for working with the API's text format and other conventions. The SDK ships utility functions for common tasks that sit outside the core request/response flow. Import them from `@betterfans/link-sdk/utils`. ## Text format The API does not accept plaintext for message and post bodies. The `text` field expects a specific subset of HTML wrapped in a `

` tag. ### Accepted tags | Tag | Purpose | |-----|---------| | `

` | Required wrapper — all text must be inside a single `

` | | `
` | Line breaks (newlines must be expressed as `
`) | | `` | Bold text | | `` | Italic text | | `` | Large heading | | `` | Medium heading | | `` | Accent colour (used with bold italic) | Any other HTML tags or attributes are stripped by the platform. ### Example The API expects text like this: ```html

Welcome!
Thanks for subscribing

``` Not this: ``` Welcome! Thanks for subscribing ``` ## `markdownToHtml` To avoid constructing HTML manually, the SDK provides a `markdownToHtml` utility that converts familiar markdown syntax into the accepted format. ```ts import { markdownToHtml } from "@betterfans/link-sdk/utils" ``` ### Supported syntax | Markdown | Result | |----------|--------| | `**bold**` | `bold` | | `*italic*` | `italic` | | `***bold italic***` | Bold italic with accent colour | | `# Heading` | Large heading | | `## Heading` | Large heading | | `### Heading` | Medium heading | | Newlines | `
` | ### Usage ```ts import { markdownToHtml } from "@betterfans/link-sdk/utils" const text = markdownToHtml("**Welcome!**\nThanks for subscribing") // →

Welcome!
Thanks for subscribing

await account.request("POST /chats/:id/messages", { pathParams: { id: "98765" }, body: { text }, }) ``` > If you pass raw plaintext without wrapping it in the expected HTML, the message will still send but formatting like newlines, bold, and headings won't render correctly in the app. --- # Revenue Pull OnlyFans transactions from a REST endpoint with your API key, or receive them as signed webhooks. Both carry the same transaction shape. Two ways to get transactions into your own system: pull them from a REST endpoint, or have them pushed to a webhook URL you set on the API Keys page. Both use the same transaction shape. ## Pull ```http GET https://api3.betterfans.link/v1/revenue/transactions x-service-api-key: ``` | Query | Meaning | | --- | --- | | `since` | RFC 3339 instant. Return rows first seen at or after it. Default: the last 24 hours. | | `cursor` | `nextCursor` from a previous page. Continue where that page ended. | | `limit` | 1 to 1000. Default 500. | | `account` | OnlyFans user id. Restrict to one account the key may act as. Required for keys that cannot list their accounts. | ```json { "transactions": [ { "id": "1234567890", "accountId": "55321703", "fanId": "88112233", "kind": "tips", "description": "Tip", "status": "done", "grossCents": 1000, "netCents": 800, "feeCents": 200, "vatCents": 0, "currency": "USD", "occurredAt": "2026-09-07T11:58:02Z", "observedAt": "2026-09-07T11:58:40.120Z", "deleted": false } ], "count": 1, "hasMore": false, "nextCursor": "", "checkedAt": "2026-09-07T12:00:00.000Z" } ``` Rows are ordered by `observedAt`, the time the row was first seen or seen to change. A transaction can appear more than once with the same `id` when its `status` or amounts change, or when it is deleted. Treat the latest row for an `id` as current. To poll, store `nextCursor` and pass it back as `cursor`. When `hasMore` is false you are caught up. Do not combine `since` and `cursor`. | Status | Meaning | | --- | --- | | 401 | Missing or invalid key. | | 403 | The key may not act as `account`. | | 400 | Bad `since` or `cursor`, or the key needs `account`. | ## Push Agency owners and admins can set one webhook URL under API Keys. Every new or changed transaction for the agency's accounts is POSTed once as JSON: ```json { "id": "e7c9d2d4-...", "type": "transaction.observed", "createdAt": "2026-09-07T11:58:41.003Z", "data": { "...": "same fields as a pull row" } } ``` | Header | Meaning | | --- | --- | | `x-ofm-event` | `transaction.observed`, or `test` for the Send test button. | | `x-ofm-delivery` | Unique id of this delivery. Use it to drop duplicates. | | `x-ofm-signature` | `t=,v1=`, where hex is HMAC-SHA256 over `.` with your signing secret. | Verify with the secret shown by Reveal secret: ```ts import { createHmac, timingSafeEqual } from "node:crypto" export function verify(rawBody: string, header: string, secret: string): boolean { const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("="))) const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex") const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300 return fresh && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? "")) } ``` Respond with any 2xx within 10 seconds. Anything else is retried four more times over about three hours, then the delivery is marked failed. The card shows the endpoint as failing until a delivery succeeds again. The URL must be public https. ---