# OnlyFans TypeScript SDK

> A fully typed client for the OnlyFans API that works in TypeScript and plain JavaScript. Call any endpoint from Node.js, Bun, Deno or a Next.js server, with automatic retries.

- One package for TypeScript, JavaScript and Node.js: there is no separate JavaScript SDK
- Requires Node.js 20+ (also Bun 1.0+, Deno 1.28+), TypeScript 4.9+ for types
- Install: `npm install @onlyfansapi/onlyfans-sdk`, import: `import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk'` or `const { OnlyFansAPI } = require('@onlyfansapi/onlyfans-sdk')`, client: `new OnlyFansAPI()`
- Source: https://github.com/onlyfansapi/onlyfansapi-typescript-sdk
- Package: `@onlyfansapi/onlyfans-sdk` on npm: https://www.npmjs.com/package/@onlyfansapi/onlyfans-sdk (Apache 2.0)

## Make your first OnlyFans call in TypeScript

Install the package, set your API key, and list your connected accounts.

The OnlyFans TypeScript SDK is on npm as @onlyfansapi/onlyfans-sdk. It has no dependencies, because it uses the fetch built into Node.js 20 and later, Bun and Deno. The types ship in the package, so there is no @types package to add.

```bash
npm install @onlyfansapi/onlyfans-sdk
# or: pnpm add @onlyfansapi/onlyfans-sdk
# or: yarn add @onlyfansapi/onlyfans-sdk
# or: bun add @onlyfansapi/onlyfans-sdk
# Deno: import OnlyFansAPI from "npm:@onlyfansapi/onlyfans-sdk"
```

Create an API key in the OnlyFansAPI dashboard and store it in the ONLYFANSAPI_API_KEY environment variable: export it in your shell, or keep it in .env and load it with --env-file. new OnlyFansAPI() reads the variable automatically, so the key stays out of your code and your Git history. You can also pass it with new OnlyFansAPI({ apiKey }).

```bash
export ONLYFANSAPI_API_KEY=your_api_key

# Node.js does not read .env files on its own. To keep the key in .env:
#   node --env-file=.env index.ts      (Node 22.18+)
#   npx tsx --env-file=.env index.ts   (Node 20)
# Next.js and Bun load .env for you.
```

List every OnlyFans account connected to your key. Each account has an acct_ ID, and you pass that ID to every other call: chats, fans, messages, media and statistics. Node.js 22.18 and later run this .ts file directly with node first-call.ts; on Node 20, use npx tsx.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// Reads ONLYFANSAPI_API_KEY from the environment
const client = new OnlyFansAPI();

// GET /api/accounts: every OnlyFans account connected to your API key
const accounts = await client.accounts.list();

for (const account of accounts) {
  console.log(`${account.id} · @${account.onlyfans_username}`);
}

// Output:
// acct_XXXXXXXXXXXXX · @sarah_k
```

## OnlyFans TypeScript SDK code examples

Typed calls for the jobs agencies automate first: tracking-link revenue, chats, messages, paid messages and media.

Read clicks, subscribers, spenders and lifetime revenue for a tracking link, and work out revenue per subscriber. Every response field is optional in the types, so the example uses ?. and ?? and compiles under strict.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// GET /api/{account}/tracking-links/{tracking_link_id}/stats
const { data } = await client.trackingLinks.getStats('123456', {
  account: 'acct_XXXXXXXXXXXXX',
});

const summary = data?.summary;
if (summary) {
  const subs = summary.subs_total ?? 0;
  const revenue = summary.revenue_total ?? 0;
  const arps = subs === 0 ? 0 : revenue / subs;

  console.log(`Clicks: ${summary.clicks_total} · Subs: ${subs}`);
  console.log(`Spenders: ${summary.spenders_total}`);
  console.log(`ARPS: $${arps.toFixed(2)} · Lifetime: $${revenue.toFixed(2)}`);
}

// Output:
// Clicks: 1240 · Subs: 300
// Spenders: 42
// ARPS: $12.40 · Lifetime: $3720.00
```

List the fans who paid after arriving through a tracking link, with what each one spent. minSpend hides anyone below your threshold.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// GET /api/{account}/tracking-links/{tracking_link_id}/spenders
const { data: spenders } = await client.trackingLinks.listSpenders('123456', {
  account: 'acct_XXXXXXXXXXXXX',
  minSpend: 10,
  limit: 50,
});

for (const fan of spenders ?? []) {
  const total = fan.revenue?.total ?? 0;
  console.log(`@${fan.username} spent $${total.toFixed(2)}`);
}

// Output:
// @sarah_k spent $189.00
```

Load unread chats for an account, with the fan, the unread count and the last message in each. filter is a typed union, so a typo like 'unred' fails at compile time.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// GET /api/{account}/chats
const { data: chats } = await client.chats.list('acct_XXXXXXXXXXXXX', {
  filter: 'unread',
  limit: '20',
});

for (const chat of chats ?? []) {
  console.log(`@${chat.fan?.username} · unread: ${chat.unreadMessagesCount}`);
  console.log(`  ${chat.lastMessage?.text}`);
}

// Output:
// @sarah_k · unread: 3
//   Hey, are you around?
```

Read the newest messages in one chat, newest first.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// GET /api/{account}/chats/{chat_id}/messages
const { data: messages } = await client.chats.messages.list('987654321', {
  account: 'acct_XXXXXXXXXXXXX',
  limit: '20',
  order: 'desc',
});

for (const message of messages ?? []) {
  console.log(`[${message.createdAt}] ${message.text}`);
}

// Output:
// [2026-09-04T18:22:41+00:00] Just posted something new for you 💜
```

Send a text with a media attachment to a fan's chat with chats.messages.send.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// POST /api/{account}/chats/{chat_id}/messages
const { data: sent } = await client.chats.messages.send('987654321', {
  account: 'acct_XXXXXXXXXXXXX',
  text: 'Hey! Thanks for subscribing 💜',
  mediaFiles: ['ofapi_media_XXXXXXXXXXXXX'],
});

console.log(`Message sent #${sent?.id}`);

// Output:
// Message sent #6182934517
```

Upload a photo with fs.createReadStream, then send it as a paid message for $10. The file streams from disk and is never loaded into memory in full. The upload returns an ofapi_media_ ID that works once, in one post or one message. Prices are in US dollars: 0 for free, or 3 to 200.

```typescript
import fs from 'node:fs';
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();
const account = 'acct_XXXXXXXXXXXXX';

// POST /api/{account}/media/upload
// The returned ID can be attached to one post or message.
const upload = await client.media.upload(account, {
  file: fs.createReadStream('preview.jpg'),
});

// POST /api/{account}/chats/{chat_id}/messages
const { data: sent } = await client.chats.messages.send('987654321', {
  account,
  text: 'Something new, just for you 💜',
  mediaFiles: [upload.prefixed_id],
  price: 10, // USD, 0 or 3–200
});

console.log(`PPV sent #${sent?.id}`);
```

Download a file from an OnlyFans CDN URL through the API's proxy. Pass the URL as it is; the SDK encodes it. Call .asResponse() and stream response.body to disk. Awaiting download() alone reads the body as text, which corrupts photos and videos.

```typescript
import fs from 'node:fs';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import type { ReadableStream } from 'node:stream/web';
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

const cdnUrl = 'https://cdn2.onlyfans.com/files/…/photo.jpg?Expires=…';

// GET /api/{account}/media/download/{cdn_url}
// asResponse() returns the raw fetch Response, so the bytes stream
// straight to disk instead of being decoded as text.
const response = await client.media
  .download(cdnUrl, { account: 'acct_XXXXXXXXXXXXX' })
  .asResponse();

await pipeline(
  Readable.fromWeb(response.body as ReadableStream),
  fs.createWriteStream('photo.jpg'),
);
```

## Features

- **Full TypeScript types**: Every parameter and response field has a type, with docs on hover. Plain JavaScript gets the same autocomplete in VS Code.
- **Retries and timeouts**: Connection errors, timeouts, 408, 409, 429 and 5xx responses are retried twice with backoff. Requests time out after 60 seconds.
- **Zero dependencies**: It uses the fetch built into Node.js 20+, Bun and Deno, so there is nothing else to install. Load it with import or require.

## Use the OnlyFans TypeScript SDK with Next.js

Keep the client on the server, read data in Server Components, and receive signed webhooks in a route handler.

Create the client once in lib/onlyfans.ts and import it everywhere. import 'server-only' makes the build fail if a Client Component imports the file, so your API key never ships to the browser. Next.js loads ONLYFANSAPI_API_KEY from .env.local.

```typescript
// lib/onlyfans.ts
import 'server-only';
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// One client for the whole app. It reads ONLYFANSAPI_API_KEY from .env.local,
// and server-only makes the build fail if a Client Component imports it,
// so the key never reaches the browser.
export const onlyfans = new OnlyFansAPI();
```

A page that lists an account's newest fans. Server Components run on the server, so they call the SDK directly, with no API route and no client-side fetch. The [account] segment makes Next.js render the page per request instead of once at build time.

```tsx
// app/accounts/[account]/fans/page.tsx
import { onlyfans } from '@/lib/onlyfans';

// A Server Component calls the SDK directly: no API route, no client-side fetch.
export default async function FansPage({ params }: PageProps<'/accounts/[account]/fans'>) {
  const { account } = await params;
  const { data } = await onlyfans.fans.listLatest(account, { limit: 20 });

  return (
    <ul>
      {(data?.users ?? []).map((fan) => (
        <li key={fan.id}>@{fan.username}</li>
      ))}
    </ul>
  );
}
```

A GET route that returns an account's latest fans as JSON, for a client component or another service. A wrong or disconnected account ID throws NotFoundError, which becomes a 404.

```typescript
// app/api/accounts/[account]/fans/route.ts
import { NotFoundError } from '@onlyfansapi/onlyfans-sdk';
import { onlyfans } from '@/lib/onlyfans';

export async function GET(_request: Request, ctx: RouteContext<'/api/accounts/[account]/fans'>) {
  const { account } = await ctx.params;

  try {
    const { data } = await onlyfans.fans.listLatest(account, { limit: 20 });
    return Response.json((data?.users ?? []).map(({ id, username, name }) => ({ id, username, name })));
  } catch (err) {
    if (err instanceof NotFoundError) return new Response(null, { status: 404 });
    throw err;
  }
}
```

OnlyFansAPI signs each webhook with an HMAC SHA256 of the raw body, sent in the Signature header. request.text() gives you that raw body. Deliveries time out after 10 seconds, so the handler queues the work and answers 204. If queueing throws, Next.js answers 500 and OnlyFansAPI's retry gets through.

```typescript
// app/api/webhooks/onlyfans/route.ts
import { enqueue } from '@/lib/queue';
import { verifySignature } from '@/lib/webhook';

export async function POST(request: Request) {
  // request.text() is the exact body OnlyFansAPI signed
  const body = await request.text();
  if (!verifySignature(body, request.headers.get('signature'))) {
    return new Response(null, { status: 401 });
  }

  // Deliveries time out after 10 seconds, so queue the work and answer 204.
  // If queueing throws, Next.js answers 500 and the retry gets through.
  await enqueue(JSON.parse(body), request.headers.get('x-ofapi-idempotency-key'));
  return new Response(null, { status: 204 });
}
```

lib/webhook.ts, shared by every webhook on this page. It hashes the raw body with your signing secret and compares the result with crypto.timingSafeEqual, so the check doesn't leak timing. Hash the bytes you received: JSON you parsed and stringified again won't match the signature.

```typescript
// lib/webhook.ts
import { createHmac, timingSafeEqual } from 'node:crypto';

// OnlyFansAPI signs the raw request body with HMAC SHA256 and sends the hex
// digest in the Signature header. Hash the exact bytes you received, not
// JSON you parsed and stringified again.
export function verifySignature(rawBody: string | Buffer, signature: string | null | undefined) {
  const secret = process.env.ONLYFANSAPI_WEBHOOK_SECRET;
  if (!secret) throw new Error('ONLYFANSAPI_WEBHOOK_SECRET is not set');

  const expected = createHmac('sha256', secret).update(rawBody).digest();
  const received = Buffer.from(signature ?? '', 'hex');
  return received.length === expected.length && timingSafeEqual(received, expected);
}
```

## Express, Hono and TanStack Start

The same signature check and queue work in any Node.js server. Here are the client, the routes for three popular servers, and the queue and worker behind them.

lib/onlyfans.ts for Express, Hono, TanStack Start and the worker. It is the Next.js client without import 'server-only', because that package throws outside Next.js. Create it once per process and share it.

```typescript
// lib/onlyfans.ts (Express, Hono, TanStack Start and the worker)
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// One client for the whole server. It reads ONLYFANSAPI_API_KEY from the
// environment, and every route, webhook and worker imports this instance.
// No 'server-only' import here: that package only works inside Next.js.
export const onlyfans = new OnlyFansAPI();
```

Express 5 with a JSON route and a webhook. express.raw() on the webhook route keeps the body as the bytes OnlyFansAPI signed; register it before any express.json() that would parse that route. Express 5 passes rejected promises to its error handler, so a failed queue call becomes a 500.

```typescript
// server.ts (Express 5)
import express from 'express';
import { NotFoundError } from '@onlyfansapi/onlyfans-sdk';
import { onlyfans } from './lib/onlyfans';
import { enqueue } from './lib/queue';
import { verifySignature } from './lib/webhook';

const app = express();

app.get('/api/accounts/:account/fans', async (req, res) => {
  try {
    const { data } = await onlyfans.fans.listLatest(req.params.account, { limit: 20 });
    res.json((data?.users ?? []).map(({ id, username, name }) => ({ id, username, name })));
  } catch (err) {
    if (err instanceof NotFoundError) return void res.sendStatus(404);
    throw err; // Express 5 passes rejected promises to the error handler
  }
});

// express.raw keeps the body as the exact bytes OnlyFansAPI signed.
// Register it on this route only, before any express.json() for it.
app.post('/webhooks/onlyfans', express.raw({ type: 'application/json' }), async (req, res) => {
  const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
  if (!verifySignature(body, req.get('signature'))) return void res.sendStatus(401);

  // Deliveries time out after 10 seconds, so queue the work and answer 204.
  // If queueing throws, Express answers 500 and the retry gets through.
  await enqueue(JSON.parse(body.toString('utf8')), req.get('x-ofapi-idempotency-key'));
  res.sendStatus(204);
});

app.listen(Number(process.env.PORT ?? 3000));
```

The same two routes in Hono, served on Node.js with @hono/node-server. c.req.text() returns the raw body for the signature check.

```typescript
// src/index.ts (Hono on Node)
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { NotFoundError } from '@onlyfansapi/onlyfans-sdk';
import { onlyfans } from './lib/onlyfans';
import { enqueue } from './lib/queue';
import { verifySignature } from './lib/webhook';

const app = new Hono();

app.get('/api/accounts/:account/fans', async (c) => {
  try {
    const { data } = await onlyfans.fans.listLatest(c.req.param('account'), { limit: 20 });
    return c.json((data?.users ?? []).map(({ id, username, name }) => ({ id, username, name })));
  } catch (err) {
    if (err instanceof NotFoundError) return c.body(null, 404);
    throw err;
  }
});

app.post('/webhooks/onlyfans', async (c) => {
  // c.req.text() is the raw body, before any JSON parsing
  const body = await c.req.text();
  if (!verifySignature(body, c.req.header('signature'))) return c.body(null, 401);

  // If queueing throws, Hono answers 500 and the retry gets through
  await enqueue(JSON.parse(body), c.req.header('x-ofapi-idempotency-key'));
  return c.body(null, 204);
});

serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) });
```

A server route in TanStack Start. server.handlers.POST receives a standard Request, so the webhook code matches the Next.js version line for line.

```typescript
// src/routes/webhooks/onlyfans.ts
import { createFileRoute } from '@tanstack/react-router';
import { enqueue } from '@/lib/queue';
import { verifySignature } from '@/lib/webhook';

export const Route = createFileRoute('/webhooks/onlyfans')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        // request.text() is the exact body OnlyFansAPI signed
        const body = await request.text();
        if (!verifySignature(body, request.headers.get('signature'))) {
          return new Response(null, { status: 401 });
        }

        // If queueing throws, TanStack Start answers 500 and the retry gets through
        await enqueue(JSON.parse(body), request.headers.get('x-ofapi-idempotency-key'));
        return new Response(null, { status: 204 });
      },
    },
  },
});
```

lib/queue.ts adds each webhook event to a BullMQ queue in Redis (install bullmq and ioredis). OnlyFansAPI retries a delivery with the same X-OFAPI-Idempotency-Key, and used as the job ID it makes BullMQ ignore the duplicate, so each event runs once. If Redis is unreachable, the request waits, OnlyFansAPI's 10-second timeout fires, and the delivery is retried.

```typescript
// lib/queue.ts
import { Queue } from 'bullmq';

export type WebhookEvent = {
  event: string;
  account_id: string;
  payload: Record<string, any>;
};

export const events = new Queue<WebhookEvent>('onlyfans-events', {
  connection: { url: process.env.REDIS_URL },
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: 'exponential', delay: 10_000 },
    removeOnComplete: { age: 86_400 }, // remember delivered keys for a day
  },
});

// Deliveries are retried with the same X-OFAPI-Idempotency-Key. Used as the
// job ID, BullMQ ignores a second job with that key, so each event runs once.
export function enqueue(event: WebhookEvent, idempotencyKey?: string | null) {
  return events.add(event.event, event, idempotencyKey ? { jobId: idempotencyKey } : {});
}
```

A separate Node.js process that welcomes new subscribers. The chat ID is the fan's user ID, so payload.fan.id is all you need to message them. A thrown error, such as RateLimitError, fails the job, and BullMQ retries it with exponential backoff, up to 5 attempts. Run it next to your app with npx tsx worker.ts. On serverless hosts such as Vercel, run the worker on a long-running server.

```typescript
// worker.ts: run it as its own long-lived process, e.g. npx tsx worker.ts
import { Worker } from 'bullmq';
import { onlyfans } from './lib/onlyfans';
import type { WebhookEvent } from './lib/queue';

new Worker<WebhookEvent>(
  'onlyfans-events',
  async (job) => {
    const { event, account_id, payload } = job.data;
    if (event !== 'subscriptions.new') return;

    // The chat ID is the fan's user ID
    await onlyfans.chats.messages.send(String(payload.fan.id), {
      account: account_id,
      text: `Welcome, ${payload.fan.name}! Thanks for subscribing 💜`,
    });
  },
  // A thrown error (a RateLimitError, say) fails the job, and BullMQ
  // retries it with the backoff set in lib/queue.ts
  { connection: { url: process.env.REDIS_URL }, concurrency: 5 },
);
```

## Use the SDK from plain JavaScript

There is one package for TypeScript and JavaScript. Use it with ES modules or CommonJS, with or without a build step.

With "type": "module" in package.json, or in a .mjs file, import the default export and use top-level await. This is the same code as the TypeScript quick start, without the types.

```javascript
// ES modules: "type": "module" in package.json, or a .mjs file
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

const accounts = await client.accounts.list();
for (const account of accounts) {
  console.log(`${account.id} · @${account.onlyfans_username}`);
}
```

Older Node projects use require. Take the named OnlyFansAPI export, const { OnlyFansAPI } = require(...), so editors and // @ts-check see the constructor's types. CommonJS has no top-level await, so wrap calls in an async function.

```javascript
// CommonJS: the default in Node when package.json has no "type"
const { OnlyFansAPI } = require('@onlyfansapi/onlyfans-sdk');

const client = new OnlyFansAPI();

async function main() {
  const accounts = await client.accounts.list();
  for (const account of accounts) {
    console.log(`${account.id} · @${account.onlyfans_username}`);
  }
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

Add // @ts-check to a JavaScript file and VS Code checks your calls against the SDK's types, with no TypeScript build. JSDoc @param and @returns tags type your own functions too.

```javascript
// @ts-check
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

/**
 * Plain JavaScript still gets the SDK's types: with // @ts-check,
 * VS Code flags wrong parameters before you run the file.
 * @param {string} account
 * @returns {Promise<OnlyFansAPI.FanListLatestResponse>}
 */
export function latestFans(account) {
  return client.fans.listLatest(account, { limit: 20 });
}

const { data } = await latestFans('acct_XXXXXXXXXXXXX');
console.log(data?.users?.map((fan) => fan.username));
```

## Call the OnlyFans API for many accounts at once

Node.js runs requests in parallel on one thread, so one client can serve every account an agency manages.

Count unread chats on every connected account, five accounts at a time, with Promise.all and one shared client. Five at a time keeps a large agency inside its rate limit. The chats endpoint has no hasMore flag, so each account keeps loading pages until _pagination.next_page is empty.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// One client for the whole process; requests run in parallel on Node's event loop
const client = new OnlyFansAPI();

const accounts = (await client.accounts.list()).flatMap((a) => (a.id ? [a.id] : []));

async function countUnread(account: string) {
  let count = 0;
  for (let offset = 0; ; offset += 100) {
    const page = await client.chats.list(account, {
      filter: 'unread',
      limit: '100',
      offset: String(offset),
    });
    count += page.data?.length ?? 0;
    // Chats have no hasMore: stop when next_page is empty
    if (!page._pagination?.next_page) break;
  }
  return [account, count] as const;
}

// Five accounts at a time, so one big agency doesn't hit the rate limit
const unread: (readonly [string, number])[] = [];
for (let i = 0; i < accounts.length; i += 5) {
  unread.push(...(await Promise.all(accounts.slice(i, i + 5).map(countUnread))));
}

for (const [account, count] of unread) {
  console.log(`${account}: ${count} unread chats`);
}
```

## Load every fan, chat or post page by page

List endpoints return one page at a time. Loop until hasMore is false and move the offset by the full page size each time.

Load every fan, 20 at a time, the most /fans/all returns per page. OnlyFans filters some entries on its side, so a page can hold 19 fans and still not be the last one. Don't stop on a short page: hasMore is the reliable end signal, and moving the offset by the full limit skips nobody.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

const account = 'acct_XXXXXXXXXXXXX';
const limit = 20;
const usernames: string[] = [];

for (let offset = 0; ; offset += limit) {
  // GET /api/{account}/fans/all
  const { data } = await client.fans.listAll(account, { limit, offset });
  if (!data) break;

  for (const fan of data.list ?? []) {
    if (fan.username) usernames.push(fan.username);
  }

  // A page can hold fewer than limit fans and still not be the last one.
  // Trust hasMore, and move the offset by the full limit.
  if (!data.hasMore) break;
}

console.log(`${usernames.length} fans loaded`);
```

Write every active fan to a CSV file for a CRM import or a spreadsheet, with no CSV library. Each field is quoted, so commas and quotes in names stay in one cell. The loop follows hasMore, so large accounts export completely.

```typescript
import fs from 'node:fs';
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// Quote every field so commas and quotes in names stay in one cell
const cell = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;

const csv = fs.createWriteStream('fans.csv');
csv.write('id,username,name\n');

for (let offset = 0; ; offset += 20) {
  // GET /api/{account}/fans/active
  const { data } = await client.fans.listActive('acct_XXXXXXXXXXXXX', { limit: 20, offset });

  for (const fan of data?.list ?? []) {
    csv.write(`${[fan.id, fan.username, fan.name].map(cell).join(',')}\n`);
  }

  if (!data?.hasMore) break;
}

csv.end();
```

## Handle errors, retries and timeouts

Every failed request throws a typed error, and short outages are retried before your code sees them.

Each HTTP status has its own error class, exported from the package and on OnlyFansAPI, and all of them extend APIError. Check the ones you can act on with instanceof, then APIError for the rest; it carries status, headers and the parsed error body. APIConnectionTimeoutError extends APIConnectionError, so check it first.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

try {
  const { data: chats } = await client.chats.list('acct_XXXXXXXXXXXXX', { limit: '10' });
  console.log(`Chats loaded: ${chats?.length ?? 0}`);
} catch (err) {
  if (err instanceof OnlyFansAPI.AuthenticationError) {
    // 401: the API key is missing, revoked or mistyped
  } else if (err instanceof OnlyFansAPI.NotFoundError) {
    // 404: wrong account ID, or the account was disconnected
  } else if (err instanceof OnlyFansAPI.RateLimitError) {
    // 429: still limited after the SDK's own retries, so back off
  } else if (err instanceof OnlyFansAPI.APIConnectionTimeoutError) {
    // no answer within the timeout, even after retries
  } else if (err instanceof OnlyFansAPI.APIConnectionError) {
    console.error(err.cause); // DNS, TLS or network failure from fetch
  } else if (err instanceof OnlyFansAPI.APIError) {
    console.error(err.status, err.error); // any other 4xx or 5xx
  } else {
    throw err;
  }
}
```

The SDK retries connection errors, timeouts, and 408, 409, 429 and 5xx responses twice. It waits about 0.5 seconds before the first retry, doubles the wait each time up to 8 seconds, and follows Retry-After. Raise maxRetries for background jobs, or pass { maxRetries: 0 } as the last argument while a user waits.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// Connection errors, timeouts, 408, 409, 429 and 5xx responses are retried
// twice by default, with exponential backoff that respects Retry-After.
const client = new OnlyFansAPI({ maxRetries: 4 });

// Or switch retries off for one call, e.g. while a user waits on the page
const { data: chats } = await client.chats.list(
  'acct_XXXXXXXXXXXXX',
  { limit: '10' },
  { maxRetries: 0 },
);
console.log(`Chats loaded: ${chats?.length ?? 0}`);
```

Requests time out after 60 seconds. Set timeout in milliseconds on the client, or for one call. A timed-out request is retried, then throws APIConnectionTimeoutError. Pass an AbortSignal to cancel a call yourself, for example when the user closes the page; it throws APIUserAbortError.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

// The default is 60 seconds per request, set in milliseconds
const client = new OnlyFansAPI({ timeout: 30_000 });

// Shorter timeout for one call; on expiry the SDK retries,
// then throws APIConnectionTimeoutError
const { data: chats } = await client.chats.list(
  'acct_XXXXXXXXXXXXX',
  { limit: '10' },
  { timeout: 10_000 },
);
console.log(`Chats loaded: ${chats?.length ?? 0}`);

// Cancel a request yourself, e.g. when the user closes the page
const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
try {
  await client.chats.list('acct_XXXXXXXXXXXXX', {}, { signal: controller.signal });
} catch (err) {
  if (!(err instanceof OnlyFansAPI.APIUserAbortError)) throw err;
}
```

New endpoints can ship before the SDK has a method for them. client.get, client.post and the other verbs call any path with the same authentication, retries and timeouts. To send a parameter the types don't know yet, put it in the params object under // @ts-expect-error. A query in request options replaces the typed parameters instead of adding to them.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

// Call an endpoint the SDK has no method for yet.
// Authentication, retries and timeouts still apply.
const response = await client.get<{ data?: unknown }>(
  '/api/acct_XXXXXXXXXXXXX/some-new-endpoint',
  { query: { limit: 10 } },
);
console.log(response.data);

// Or send a parameter the SDK types don't have yet. Put it in the
// params object; passing { query } in request options would replace
// the typed params instead of adding to them.
const { data: chats } = await client.chats.list('acct_XXXXXXXXXXXXX', {
  filter: 'unread',
  // @ts-expect-error new_filter is not in the SDK types yet
  new_filter: 'value',
});
console.log(`Chats loaded: ${chats?.length ?? 0}`);
```

## Track credits and rate limits from TypeScript

Every response reports what the call cost and how much room you have left, so you can slow down before you hit a limit.

Read _meta on any response. Inside are _credits, _rate_limits and _cache, typed and named as in the JSON: credits used and left, requests left this minute and today, and whether the answer came from cache. Cached responses cost 0 credits but still count toward your rate limit.

```typescript
import OnlyFansAPI from '@onlyfansapi/onlyfans-sdk';

const client = new OnlyFansAPI();

const fans = await client.fans.listAll('acct_XXXXXXXXXXXXX', { limit: 20 });

// Every response carries _meta with credits, rate limits and cache state.
const { _credits, _rate_limits, _cache } = fans._meta ?? {};
console.log(`Credits used: ${_credits?.used}`);
console.log(`Credits left: ${_credits?.balance}`);
console.log(`Requests left this minute: ${_rate_limits?.remaining_minute}`);
console.log(`From cache: ${_cache?.is_cached ? 'yes' : 'no'}`);

// Output:
// Credits used: 1
// Credits left: 9876
// Requests left this minute: 998
// From cache: no
```

## What TypeScript teams build with the OnlyFans API

- **Agency dashboards**: Show earnings, tracking-link revenue and fan lists in a Next.js app, rendered in Server Components so the API key stays on the server.
- **AI chat assistants**: Read chat history, draft replies with the Vercel AI SDK or the OpenAI Node SDK, and send them back with chats.messages.send.
- **Webhook automations**: Welcome new subscribers, tag big spenders or alert your team in Slack from a signed webhook route and a BullMQ worker.
- **Scheduled syncs**: Copy fans, transactions and statistics into Postgres with Prisma or Drizzle, on a cron job or a scheduled worker.
- **Media pipelines**: Stream files from disk or S3 into posts and paid messages, and stream originals back out for backup without holding them in memory.
- **Discord and Telegram bots**: Post new tips and messages to your team's channel, or answer "how much did we make today?" with discord.js or grammY.

## FAQ

### How do I install the OnlyFans TypeScript SDK?

Run npm install @onlyfansapi/onlyfans-sdk, or pnpm add, yarn add or bun add with the same name. In Deno, import it from npm:@onlyfansapi/onlyfans-sdk. The types come with the package.

### Is there a separate JavaScript or Node.js SDK?

No. The TypeScript SDK is the JavaScript SDK: the npm package ships compiled JavaScript for both ES modules (import) and CommonJS (require), plus type definitions. Node.js projects use it with or without TypeScript.

### Which Node.js and TypeScript versions are supported?

Node.js 20 and later, Bun 1.0 and later, Deno 1.28 and later, and TypeScript 4.9 and later. We tested the examples on this page on Node.js 20 and 24, Bun 1.4 and Deno 2.9.

### Can I call the SDK from the browser or a React component?

Not with your API key. Any code that runs in the browser exposes the key to whoever opens the page. Call the SDK from server code instead (Server Components, route handlers, server functions or an API server) and send your frontend only the data it needs. In Next.js, import 'server-only' enforces this at build time.

### How do I use it with Next.js?

Create the client in lib/onlyfans.ts with import 'server-only', then call it from Server Components and route handlers. For webhooks, read the raw body with await request.text() before you verify the signature. The Next.js section above shows each file.

### How do I verify webhook signatures in Node.js?

Compute createHmac('sha256', secret).update(rawBody).digest() and compare it with the hex Signature header using crypto.timingSafeEqual. Use the raw body: request.text() in Next.js, Hono and TanStack Start, or express.raw() in Express. JSON you parsed and stringified again won't match.

### What happens when a request fails?

The SDK throws a subclass of APIError, such as AuthenticationError, NotFoundError, RateLimitError or APIConnectionTimeoutError. Connection errors, timeouts, and 408, 409, 429 and 5xx responses are first retried twice with exponential backoff.

### How do I download photos and videos without corrupting them?

Call client.media.download(url, { account }).asResponse() and stream response.body to a file or to storage. Awaiting download() on its own decodes the body as text, which breaks binary files.

### Is the OnlyFans TypeScript SDK free?

Yes. The package is open source under the Apache 2.0 license and free to install. The API calls it makes use credits from your OnlyFansAPI plan.

## Links

- Get your API key: https://app.onlyfansapi.com/register
- TypeScript SDK docs: https://github.com/onlyfansapi/onlyfansapi-typescript-sdk#readme
- Source on GitHub: https://github.com/onlyfansapi/onlyfansapi-typescript-sdk
- npm package: https://www.npmjs.com/package/@onlyfansapi/onlyfans-sdk
- API reference: https://docs.onlyfansapi.com/introduction
- Webhook signatures: https://docs.onlyfansapi.com/webhooks/protecting-your-webhooks
- All SDKs: https://onlyfansapi.com/sdk