OnlyFans Fan Segments API
First, the vocabulary, because it decides what you type. "Segment" is the word for the idea; list is the word in the API. There is no /segments route and never has been. A segment is an OnlyFans list you populate from your own scoring, and everything below lives under /accounts/{accountId}/lists.
If what you want is the list object itself — the built-in Fans and Following collections, their flags and ordering — that is the lists API. This page is about the loop you end up automating: score, populate, address, prune.
Endpoints
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 form, because a list belongs to exactly one creator's session. listId is a string, not an integer — "123", not 123. Treat it as opaque and never parse it.
Parameters and bodies
| Route | Param / field | Default | Notes |
|---|---|---|---|
GET /lists |
offset |
0 |
zero-based |
GET /lists |
limit |
10 |
clamped to 1–100 |
GET /lists/{listId}/users |
offset |
0 |
zero-based |
GET /lists/{listId}/users |
limit |
10 |
clamped to 1–100 |
POST /lists |
name |
— | required; blank gives 400 BAD_REQUEST |
POST /lists/{listId}/users |
userIds |
[] |
array of integers; empty gives 400 BAD_REQUEST |
Note the default limit of 10 on both reads — half what the rest of the API uses. A segment with 400 members is 40 requests unless you raise it.
Create and populate
curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/lists \
-H "Authorization: Bearer ofapis_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: seg-whales-2026-02" \
-d '{"name":"Whales Q1"}'
{ "data": { "id": "456", "name": "Whales Q1", "usersCount": 0,
"canDelete": true, "canManageUsers": true } }
Keep the returned id. Both POSTs honour Idempotency-Key: a repeat replays the stored response with Idempotency-Replayed: true and is not billed again, which is what stops a retried nightly job from creating "Whales Q1" four times.
Adding members takes the whole batch in one call — {"userIds":[98765,11223]} — and returns {"data":{"added":[98765,11223]}}. One call, one credit, however many ids are in it, so batch generously instead of looping one id per request. Removal is the opposite shape: one fan per DELETE, returning the list with its new usersCount.
Reading a segment back
curl "https://api.ofapis.com/api/public/v1/accounts/42/lists/456/users?limit=100&offset=0" \
-H "Authorization: Bearer ofapis_sk_..."
{ "data": { "list": [ { "id": 98765, "name": "Fan Name", "username": "fan_handle",
"subscribedBy": true } ], "hasMore": true, "nextOffset": 10 } }
A member row is considerably richer than a row from the subscribers API: it also carries subscribedByExpireDate, subscribedByAutoprolong, currentSubscribePrice, subscribePrice, lastSeen, canReceiveChatMessage, tipsEnabled, isBlocked, isRestricted and canTrialSend. It also carries listsStates — an array of {id, type, name, hasUser} describing that fan's membership across the account's other lists, which is how you detect a fan sitting in both "Whales" and "Churn risk" without a second read.
GET /lists returns {list, hasMore} with no nextOffset; the members route does return one. Either way, drive paging from your own counter.
Reconciling membership
A list is stored membership, not a saved query. It does not re-evaluate itself when the signals you scored a fan on change, so the job is a diff.
def reconcile(account_id, list_id, want: set[int]):
"""Make the list contain exactly `want`. Returns credits spent."""
have, offset, spent = set(), 0, 0
while True:
r = S.get(f"{BASE}/accounts/{account_id}/lists/{list_id}/users",
params={"limit": 100, "offset": offset}, timeout=30)
r.raise_for_status(); spent += 1
body = r.json()["data"]
have |= {u["id"] for u in body["list"]}
if not body["hasMore"] or not body["list"]:
break
offset += 100
add, drop = want - have, have - want
if add: # one call for the whole batch
S.post(f"{BASE}/accounts/{account_id}/lists/{list_id}/users",
json={"userIds": sorted(add)},
headers={"Idempotency-Key": f"{list_id}-add-{min(add)}-{len(add)}"})
spent += 1
for uid in drop: # one call each
S.delete(f"{BASE}/accounts/{account_id}/lists/{list_id}/users/{uid}")
spent += 1
return spent
Cost is ceil(members / 100) + 1 + len(drop) credits. Removals dominate, so a cohort that churns hard is cheaper rebuilt than edited: delete the list, create it, add the batch.
Addressing the cohort
The payoff is sending to the segment as one unit rather than looping over fans.
- Scheduled DM.
POST /accounts/{accountId}/scheduled/messagesacceptsuserLists: ["456"]alongside or instead ofuserIds, and delivers at yourscheduledAt. Costs 3 credits to create — see the scheduling API. - Mailing. Create a mailing with
listId: "456", call preview forrecipientCountandestimatedCost, then trigger. Billing isrecipientCount × perRecipientWeight(1 credit per recipient by default), so a 3,120-member segment is 3,120 credits. Details on the mass DM API; a priced one is the engine behind scheduled PPV drops.
Preview before you trigger. The count reflects the segment at call time, not at the time you created the mailing — and a reconcile that ran in between will have changed it.
Errors and limits
| Status | error.code |
Cause |
|---|---|---|
| 400 | BAD_REQUEST |
blank name, or an empty userIds array |
| 402 | INSUFFICIENT_CREDITS |
balance below the call's weight |
| 404 | bare text body | accountId not on this token |
| 409 | ACCOUNT_NOT_ACTIVE |
account linked but disconnected |
| 409 | IDEMPOTENCY_CONFLICT |
same key already in flight |
| 424 | OF_SESSION_EXPIRED |
stored OnlyFans session needs re-linking |
| 429 | RATE_LIMIT_EXCEEDED |
60/100/500 requests per minute by plan |
Every list call is one credit, debited before the handler runs and refunded on 5xx, 424 and 409 — so a reconcile that dies halfway through a drop loop only pays for the deletes that landed. Watch the X-RateLimit-Remaining header during a large rebuild: a 900-member cohort is nine reads plus one add plus however many deletes, which is a real fraction of a Free-plan minute. The per-plan numbers are on rate limits and pricing.
FAQ
Is there a /segments endpoint?
No. Segments are OnlyFans lists, addressed at /accounts/{accountId}/lists, and any documentation showing a /segments path is describing something that does not exist. The marketing word and the API word differ on purpose.
Why is my list only returning ten members?
Because limit defaults to 10 on both list routes, not the 20 used elsewhere in the API. Pass limit=100 explicitly for any sweep; the clamp is 1–100 either way.
Can one fan belong to several segments at once?
Yes, and each member row reports it: the listsStates array names every list on the account with a hasUser flag for that fan. Use it to suppress a fan from a win-back send when they already sit in a VIP cohort.
How do I remove several fans in one call?
You cannot — removal is one DELETE per targetUserId, while adding takes an array. When the drop set is large, deleting the whole list and recreating it from scratch is fewer calls and fewer credits than removing members one at a time.
Does adding a fan who is already in the segment cost extra?
It costs the one credit the call costs, and the response's added array tells you what the operation actually did. Reconciling against a read first is usually cheaper than blind re-adds, because one read page covers a hundred members.