ofapisv1
capability

Updated

OnlyFans Lists API

A list is OnlyFans' own grouping object: a string id, a name, a member count, and capability flags saying what you may do to it. Six routes cover it. This page is the endpoint reference — paths, envelopes, the default that bites, and the call arithmetic of syncing a list against a set you computed elsewhere. What belongs on a list and why is answered instead on the fan segments API page.

The six routes

GET    /api/public/v1/accounts/{accountId}/lists
POST   /api/public/v1/accounts/{accountId}/lists
DELETE /api/public/v1/accounts/{accountId}/lists/{listId}
GET    /api/public/v1/accounts/{accountId}/lists/{listId}/users
POST   /api/public/v1/accounts/{accountId}/lists/{listId}/users
DELETE /api/public/v1/accounts/{accountId}/lists/{listId}/users/{targetUserId}

Every route is account-scoped; there is no unscoped /lists, because a list exists only inside one creator's OnlyFans session. Three id spaces appear in those paths and they are easy to cross: accountId is the ofapis integer identifying a linked session on your token, listId is an opaque string from OnlyFans, and targetUserId is the fan's numeric OnlyFans user id — the same value that serves as a chatId in the chat API. Note what is absent: no PUT, no PATCH.

The list object

Both GET /lists and POST /lists return the same shape per list:

Field Type What it is
id string the listId used in every other route
type string OnlyFans' own classification of the list
name string display name, set once at creation
usersCount integer current membership size
postsCount integer posts attached to the list
order, direction, sortList string, array how OnlyFans renders it
canUpdate boolean reported upstream; no update route exists here
canDelete boolean whether DELETE /lists/{listId} will be accepted
canManageUsers, canAddUsers boolean whether membership can be changed
canPinnedToFeed, isPinnedToFeed boolean feed pinning, capability and state
canPinnedToChat, isPinnedToChat boolean chat pinning, capability and state
users array id-only member references, when included

Read the flags before you write. A system list reporting canManageUsers or canAddUsers false will reject a membership change upstream, and skipping that call is cheaper than discovering the rejection. The same check exists one level up: canCreateLists on the creator's own profile tells you whether POST /lists is worth attempting at all. canUpdate is the odd one out — reported faithfully, with no update route to act on.

Enumerating lists

curl "https://api.ofapis.com/api/public/v1/accounts/42/lists?limit=100&offset=0" \
  -H "Authorization: Bearer ofapis_sk_..."
{ "data": { "list": [ { "id": "123", "name": "VIP Fans", "usersCount": 42,
                        "canDelete": true, "canManageUsers": true } ],
            "hasMore": false } }

The envelope is {list, hasMore} plus account-level order and sort. There is no nextOffset here — the members route returns one, this one does not, so drive paging from your own counter. GET /lists/{listId}/users uses {list, nextOffset, hasMore}, and its rows are full user profiles rather than id stubs, the one place this resource beats the subscribers API — and the cheapest place to start fan-record enrichment.

The default that costs you nine calls

Both reads take offset (default 0) and limit, and limit defaults to 10 on both — not the 20 used by /subscribers, /chats, /notifications and the rest of the API. The clamp is 1–100 either way, applied server-side, so an out-of-range value is corrected, not rejected.

A 900-member list swept at the default is 90 requests and 90 credits; at limit=100 it is 9 of each. Against the Free plan's 60 requests per minute that is the difference between finishing inside a minute and bouncing off rate limits. Pin limit on every list read — the curl audit walkthrough pages a roster from the shell the same way.

Changing membership

The two write shapes are asymmetric, and that asymmetry drives the section below.

curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/lists/123/users \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: list-123-add-2026-03-01" \
  -d '{"userIds":[98765,11223,44556]}'

One call, any number of ids. The response is {"data":{"added":[98765,11223,44556]}}. An empty userIds array is rejected with 400 BAD_REQUEST before anything upstream is touched, as is a blank name on POST /lists. There is no batch form for removal.

POST /lists/{listId}/users/{targetUserId} does not exist — that path is DELETE only, one fan per call. It returns {"data":{"list":{...},"userState":{...}}}: the updated list with its decremented usersCount, plus a {id, type, name, hasUser} record for the fan removed. Since hasUser reflects the state afterwards, that response is your confirmation and saves a verification read.

Reconciling against a desired set

A list stores membership; it does not re-evaluate a query. Whatever decided who belongs, you own the job of making the stored set match — and that job's cost is closed-form. Let n be the current member count, A the ids to add and D the ids to drop:

edit    = ceil(n / 100)  +  (A ? 1 : 0)  +  |D|     credits
rebuild = 1 (delete)     +  1 (create)   +  1 (add) = 3 credits

Editing is linear in removals; rebuilding is constant. A 600-member list losing 40 fans costs 6 + 1 + 40 = 47 credits to edit and 3 to rebuild. The catch: a rebuild mints a new listId, breaking every stored reference. Rebuild when the list is resolved by name at send time; edit when the id is pinned in config.

const BASE = "https://api.ofapis.com/api/public/v1";
const H = { Authorization: `Bearer ${process.env.OFAPIS_TOKEN}` };

async function reconcile(accountId, listId, want) {
  const root = `${BASE}/accounts/${accountId}/lists/${listId}/users`;
  const have = new Set();
  for (let offset = 0; ; offset += 100) {
    const r = await fetch(`${root}?limit=100&offset=${offset}`, { headers: H });
    if (!r.ok) throw new Error(`${r.status} ${(await r.json()).error.code}`);
    const { data } = await r.json();
    data.list.forEach((u) => have.add(u.id));
    if (!data.hasMore || data.list.length === 0) break;
  }

  const add = [...want].filter((id) => !have.has(id));
  const drop = [...have].filter((id) => !want.has(id));

  if (add.length) {
    await fetch(root, {
      method: "POST",
      headers: { ...H, "Content-Type": "application/json",
                 "Idempotency-Key": `${listId}-add-${add.length}-${Math.min(...add)}` },
      body: JSON.stringify({ userIds: add }),
    });
  }
  for (const id of drop) await fetch(`${root}/${id}`, { method: "DELETE", headers: H });
  return { added: add.length, dropped: drop.length };
}

The Idempotency-Key on the add earns its place: a nightly job retrying after a timeout would otherwise re-send the whole batch. A replay returns the stored response with Idempotency-Replayed: true and is not billed again. Deletes are naturally idempotent and need no key.

Billing and failure behaviour

Every list call carries the default weight of 1 credit, debited before the handler runs — per read page, per create, per batch add however many ids it holds, per removal. 401, 402, 403 and 429 are refused before metering and cost nothing; 409, 424 and 5xx are debited then refunded; 400 and 404 are charged and stay charged, so a typo'd accountId is a paid mistake. Full matrix on the error reference.

Once membership is right the list becomes a send target: listId on a mailing from the mass DM API, or userLists on a queued send via the scheduling API.

FAQ

Can I rename a list through the API?

No. The surface is two GETs, two POSTs and two DELETEs — no PUT or PATCH on /lists/{listId} — so name is write-once. The workaround: create a replacement, add the same members in one batch call, delete the original, update every reference to the new listId.

Why does POST /lists/{listId}/users/{targetUserId} fail?

Because that path is wired as DELETE only. Adding goes to the collection — POST /lists/{listId}/users with a {"userIds":[...]} body — while the per-user path removes. Putting the id in the URL instead of the body is the most common mistake here.

What tells me whether a built-in list accepts membership changes?

The canManageUsers and canAddUsers booleans on the list object. OnlyFans reports them per list, and a false value means the write is refused upstream whatever your token can do. Read them once per sync run and branch on them rather than attempting the write and handling the failure.

Is the listId an integer I can store as a number?

No — it is typed as a string throughout and should be stored and compared as one. Today's values look numeric, but parsing them to an integer is the kind of assumption that breaks quietly later.

Can two linked creators share a list?

No. accountId is in every path and the list lives inside that creator's session. The same cohort across five creators under one Pro token means five lists with five ids, reconciled separately — budget credits per creator, not per cohort, as multi-account patterns describes.

Start free · Build a CRM · Pricing

Related guides

All guides
Start free — 250 credits, no card
Generate a token and make your first call in minutes.
Get started