The Only API docs

Fansly vault & media

The media library, the upload pipeline, who can see what, and how a voice note actually gets sent.

Everything on this page is Fansly only. An OnlyFans account gets 501 platform_not_supported from these routes; its equivalents live on the /api2/v2/* passthrough.

The one thing that catches everyone

Fansly has two ids for a piece of media, and only one of them can be attached to a message.

idwhat it isattachable
media_idthe file in the vaultno
content_idthe file plus a permission gateyes

Attaching a media_id returns 200 and delivers a message with nothing in it. If that message was a PPV, the fan pays for an empty unlock. Every route below that produces media returns both, and the one you want is content_id.

Browse the vault

Fansly organises the vault into collections (its albums). Three of them are system buckets — All, Posts, Messages — auto-populated and undeletable; they come back with system: true.

curl "$BASE/api/crm/$CRM/accounts/$OFUID/vault/collections" \
  -H "X-API-Key: $KEY"
{ "success": true,
  "collections": [
    { "id": "9001", "name": "All", "system": true, "mediaCount": 240 },
    { "id": "9002", "name": "white bra", "system": false, "mediaCount": 24 }
  ] }

Then read one collection's media:

curl "$BASE/api/crm/$CRM/accounts/$OFUID/vault/media?collectionId=9002&limit=50" \
  -H "X-API-Key: $KEY"
{ "success": true,
  "media": [
    { "id": "m1", "kind": "photo", "mimetype": "image/jpeg",
      "url": "https://cdn1.fansly.com/…?Policy=…&Signature=…",
      "filename": "red.jpg", "width": 1080, "height": 1350,
      "durationSec": null, "collectionIds": ["9002"] }
  ],
  "hasMore": true, "nextCursor": "m1" }

Those URLs expire, and they are pinned to our IP

url is a signed CDN link with roughly a week of life, scoped to this API's egress IP address. Fetch or relay it now; storing it in your own database gives you a dead link, and handing it to a browser on another network gives you a 403. Re-list to get a fresh one.

Paging uses before/after cursors rather than an offset — pass the last row's id as before for the next page.

Create and delete collections

curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/vault/collections" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"title": "summer set", "description": ""}'

curl -X DELETE "$BASE/api/crm/$CRM/accounts/$OFUID/vault/collections/9002" \
  -H "X-API-Key: $KEY"

Deleting a collection removes the grouping, not the media inside it.

Moving media between collections is not exposed. Fansly's own request body for that action has never been captured from a live account, and this API does not guess at writes against a creator's real library.

Upload

curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media" \
  -H "X-API-Key: $KEY" \
  -F "[email protected]" \
  -F "price=10" \
  -F "require_subscription=true"

Or JSON, and we fetch the file server-side:

curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"source_url": "https://example.com/voice.m4a", "price": 10}'
{ "success": true, "platform": "fansly",
  "media": { "media_id": "6002", "content_id": "7003", "mimetype": "audio/mp4" } }

Behind that single call: reserve the upload on Fansly's media host, PUT the bytes to a presigned S3 URL, finalize, poll until transcoding finishes, then wrap the result in a gated accountMedia. You orchestrate none of it.

Fansly transcodes on the way through, so an uploaded WAV comes back as audio/mp4. Unlike OnlyFans, the file appears in the vault immediately.

Pass wrap=false to upload into the vault without gating it yet — you get a media_id and a null content_id, and apply the gate later.

Who can see it

Gating happens either inline with the upload (above) or afterwards:

curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/media/6002/permissions" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"price": 10, "require_subscription": true}'
FieldEffect
(nothing)Free — no unlock step
pricePay-per-view, in dollars
require_followFan must follow
require_subscriptionFan must subscribe (any tier)
subscription_tier_id…that specific tier
whitelistNamed fans get in regardless of the gate
preview_idA free preview clip shown before unlocking

Gates combine, and unlocking is an OR. price plus require_subscription shows the fan two doors — "pay $10" or "subscribe to a tier" — and either one opens it. The audience nests: everyone → follow → subscribe → purchase, and a subscriber is already a follower. So require the lowest gate you actually want, not all of them.

whitelist is an override, not a gate. Fansly auto-whitelists a DM's recipient, which is why a fan can always open what you sent them even when the media is paid.

The response carries the content_id to attach.

Send it

curl -X POST "$BASE/api/crm/$CRM/accounts/$OFUID/chats/$FANID/messages" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"text": "listen 💕", "content_ids": ["7003"]}'

A voice note is nothing special — it is an audio file that went through the three steps above. Upload, gate, attach.

scheduled_for takes a future unix timestamp (0 or omitted sends now), and reply_to_id makes the send a reply.

Note that a Fansly PPV is a gated attachment, not a price on the send: the money lives on the media, set when you upload or gate it.

What is deliberately missing

These exist on Fansly but are not exposed here, because their request bodies have never been captured from a live account. Guessing at them means sending a real message to a real audience, or writing to a creator's real library:

  • The broadcast send (GET /accounts/{of_user_id}/broadcasts/scheduled reads the schedule; there is no send)
  • Adding or removing media from a collection, and renaming a collection
  • Message likes and read receipts

Until then, the raw proxy will forward whatever you can construct yourself.

On this page