ofapisv1
capability

Updated

OnlyFans Messaging API

This page is about what happens inside one thread: reading its history and writing into it. Finding the right thread in the first place is the job of the chat API, which hands you the chatId used below — the fan's numeric OnlyFans user id.

Endpoints

GET  /api/public/v1/chats/{chatId}/messages
POST /api/public/v1/chats/{chatId}/messages
GET  /api/public/v1/accounts/{accountId}/chats/{chatId}/messages
POST /api/public/v1/accounts/{accountId}/chats/{chatId}/messages

Reading a thread

History is a cursor, not an offset. There is no offset parameter here.

Param Type Default Notes
limit integer 20 clamped to 1–100
olderThanMessageId integer — optional; returns messages older than this id
curl "https://api.ofapis.com/api/public/v1/chats/98765/messages?limit=30" \
  -H "Authorization: Bearer ofapis_sk_..."
{
  "data": {
    "list": [
      {
        "id": 111222333,
        "text": "Hello!",
        "createdAt": "2025-06-01T10:05:00+00:00",
        "isFree": true,
        "price": 0,
        "lockedText": false,
        "mediaCount": 0,
        "isTip": false,
        "isOpened": false,
        "isNew": true,
        "fromUser": { "id": 98765 }
      }
    ],
    "hasMore": false,
    "nextMessageId": 111222333
  }
}

The list is newest-first. nextMessageId is the id of the last (oldest) item in the page — feed it back as olderThanMessageId to walk backwards in time. It is null when the page is empty, which is the one condition that reliably ends the loop.

As in the chat list, fromUser and toUser are id-only references; the upstream call sets skip_users=all. Resolve display names through the user lookup routes rather than expecting them inline.

A message object carries more than the example shows. The fields worth branching on:

Field Type Meaning
id integer message id; also the pagination cursor
text string plain body
createdAt string ISO-8601
price integer cents; 0 on a free message
isFree boolean viewable without purchase
lockedText boolean the text itself is behind the paywall
canPurchase boolean the fan can still buy it
isTip boolean the message is a tip — see tips
mediaCount, media, previews integer, arrays attachments and unlocked thumbnails
isMediaReady boolean attached media finished processing
isOpened, isNew, isLiked boolean read and reaction state
queueId, isFromQueue, scheduledAt integer, boolean, string set when the message came out of a scheduled send
isDone, isCanceled, isPinned boolean delivery and thread state
giphyId string present instead of media on a GIF

isFromQueue is the one that catches people: a message your own scheduling job queued appears in history like any other, so an auto-responder that replies to "the newest message" needs to skip it or it will answer itself.

import os, requests

BASE = "https://api.ofapis.com/api/public/v1"
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['OFAPIS_TOKEN']}"

def history(chat_id, account_id=None, limit=100, stop_before=None):
    """Yield a thread newest-first, stopping at a message id you already stored."""
    prefix = f"/accounts/{account_id}" if account_id else ""
    cursor = None
    while True:
        params = {"limit": limit}
        if cursor:
            params["olderThanMessageId"] = cursor
        r = S.get(f"{BASE}{prefix}/chats/{chat_id}/messages", params=params, timeout=30)
        r.raise_for_status()
        body = r.json()["data"]
        for m in body["list"]:
            if stop_before and m["id"] <= stop_before:
                return
            yield m
        cursor = body.get("nextMessageId")
        if not body.get("hasMore") or cursor is None:
            return

Pass stop_before the newest id you have already persisted and an incremental sync costs one credit instead of re-reading the whole conversation.

Sending

POST takes a JSON body. Only text is required, and the step-by-step send walkthrough runs the same call end to end if you want the narrated version.

Field Type Default Notes
text string — required; plain text body
price integer 0 PPV price in cents; 0 sends free
lockedText boolean false locks the text behind the paywall; requires price > 0
mediaFiles array of integers [] vault media ids to attach
previews array of integers [] vault media ids used as unlocked thumbnails
isForward boolean false marks the message as a forward
# free reply
curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: welcome-98765-2026-02-11" \
  -d '{"text":"Thanks for subscribing!"}'

# $12.99 PPV with one locked video and one visible preview frame
curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"text":"New set, 9 min","price":1299,"lockedText":false,
       "mediaFiles":[777888999],"previews":[777889000]}'

The response is the created message object in the same shape as a history item, with price, isFree and canPurchase reflecting what you sent. mediaFiles must already exist in the vault — upload them first through the media upload API, which returns the mediaId you reference here. Pricing semantics are covered in depth on the PPV API page.

Idempotency

POST accepts an Idempotency-Key header, and you should use it. A repeat of the same key replays the original stored response with Idempotency-Replayed: true and is not charged again; a second request arriving while the first is still in flight gets 409 IDEMPOTENCY_CONFLICT. Keys are scoped to the API token and the path, so welcome-{fanId}-{date} is a safe shape — and the one a welcome-message job needs to guarantee nobody is greeted twice. Without a key, a retried job double-posts — the endpoint does no deduplication of its own.

Cost and limits

A read is 1 credit. The account-scoped send is configured at 2 credits; the unscoped /chats/{chatId}/messages send falls through to the default weight of 1. Debits happen before the handler runs and are refunded on 5xx, 424 and 409. Sends count against the same per-token ceiling as reads — 60/100/500 requests per minute by plan — so drain a backlog with a worker that honours Retry-After rather than a tight loop. One-to-many belongs on the mass DM API, which bills per recipient and previews the cost first.

FAQ

How do I page backwards through a long conversation?

Call GET /chats/{chatId}/messages, take nextMessageId from the response, and pass it as olderThanMessageId on the next call. Repeat until hasMore is false or nextMessageId comes back null. There is no offset on this route — an offset would drift as new messages arrive at the top.

Can I attach media or charge for a message?

Yes. Set price in cents and pass vault media ids in mediaFiles; add previews for the thumbnails that stay visible before purchase. Setting lockedText: true additionally hides the text body, and only makes sense with a non-zero price.

What stops a retry from sending the message twice?

The Idempotency-Key header. A repeat with the same key replays the stored response and is not billed again, and a concurrent duplicate is rejected with 409 rather than delivered. Nothing else deduplicates, so a job that may run twice must send a key.

Why are fromUser and toUser missing names?

Both are id-only references because the upstream request skips user expansion. That keeps the payload small on a 100-message page; resolve the handful of ids you actually render — the fan record behind an id costs one credit — and cache them locally.

Does a PPV send cost more credits than a free one?

No. The credit weight depends on the route, not the message price — the scoped send is 2 credits whether the message is free or costs $50. The price field is what the fan pays OnlyFans, and has nothing to do with your API balance; see pricing.

Start building: get a token free or try the Python quickstart.

Related guides

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