ofapisv1
capability

Updated

OnlyFans Scheduling API

Two queues, one namespace. Scheduled posts go to the feed; scheduled messages go to fans' inboxes. They share the /accounts/{accountId}/scheduled prefix and the same timestamp rules, and nothing else — different bodies, different ids, different delete routes.

GET    /api/public/v1/accounts/{accountId}/scheduled/posts
GET    /api/public/v1/accounts/{accountId}/scheduled/posts/counters
POST   /api/public/v1/accounts/{accountId}/scheduled/posts
DELETE /api/public/v1/accounts/{accountId}/scheduled/posts/{postId}
POST   /api/public/v1/accounts/{accountId}/scheduled/messages
DELETE /api/public/v1/accounts/{accountId}/scheduled/messages/{queueId}

Every route is account-scoped. There is no unscoped variant and no in-place edit anywhere in this group.

Reading the queue is a date-range query

This is the part that surprises people: GET /scheduled/posts is not a paginated list of "everything queued". It is a window, and both ends are required.

Param Type Required Default Notes
publishDate YYYY-MM-DD yes — start of the window
publishDateEnd YYYY-MM-DD yes — end of the window
timeZone TZ database name no UTC e.g. America/New_York
limit integer no 20 clamped to 1–100; posts route only

Omit either date and the request fails with a plain 400 before it reaches the envelope. timeZone decides which calendar day an item falls on — a 23:00 America/New_York post is the next day in UTC, so a dashboard that renders local days must pass the creator's zone to both reads or the badges and the list will disagree.

curl -G "https://api.ofapis.com/api/public/v1/accounts/42/scheduled/posts" \
  --data-urlencode "publishDate=2026-03-01" \
  --data-urlencode "publishDateEnd=2026-03-31" \
  --data-urlencode "timeZone=America/New_York" \
  --data-urlencode "limit=100" \
  -H "Authorization: Bearer ofapis_sk_..."
{
  "data": {
    "list": [
      { "id": 111, "type": "message",
        "publishDateTime": "2025-07-01T12:00:00+00:00",
        "createdDateTime": "2025-06-10T09:00:00+00:00" }
    ],
    "syncInProcess": false
  }
}

Two details to build against. Items carry a type — post or message — so this read spans both queues despite the path saying posts. And syncInProcess reports that the queue is still being reconciled upstream; while it is true, treat the page as provisional rather than caching it as the state of the calendar.

Counters are the cheap read

curl -G "https://api.ofapis.com/api/public/v1/accounts/42/scheduled/posts/counters" \
  --data-urlencode "publishDate=2026-03-01" \
  --data-urlencode "publishDateEnd=2026-03-31" \
  -H "Authorization: Bearer ofapis_sk_..."
{ "data": { "list": { "2025-07-01": { "message": 3, "post": 1 } },
            "syncInProcess": false } }

list is a map keyed by date, valued by per-type counts — exactly the shape a month grid needs. One credit renders the whole month's badges; fetch the full item list only for the day a user actually opens. A calendar built the other way round costs a credit per month view and tells you less.

Scheduling a post

curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/scheduled/posts \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: drop-2026-03-14-18z" \
  -d '{"mediaFiles":[88123,88124],"isScheduled":1,"scheduledAt":"2026-03-14T18:00:00Z"}'
Field Type Default Notes
mediaFiles array of integers [] vault media ids
isScheduled integer 1 1 = fire at scheduledAt; 0 = "publish later" queue
isPublishedWithPeriod integer 0 periodic publishing flag
scheduledAt string null ISO-8601 instant; required when isScheduled=1

isScheduled: 0 is the undated queue — no scheduledAt needed, the item waits to be published later. With isScheduled: 1 the timestamp is parsed as an Instant and must be in the future, which produces three distinct 400s worth handling separately: scheduledAt is required when isScheduled=1, scheduledAt must be a valid ISO-8601 UTC instant, and scheduledAt must be a future date/time. Send 2026-03-14T18:00:00Z, not 2026-03-14 18:00. Media must already exist in the vault — upload first with the media upload API, or reuse an id you already hold by reading the vault index, and see the posts API for what the feed side accepts. Response is {"data":{"id":222333444}}.

Scheduling a message

A different body, and the only place in the API where you can aim a future send at a whole cohort.

Field Type Default Notes
scheduledAt string — required, ISO-8601 instant, future
text string "" message body
mediaFiles array of integers [] vault media ids
price integer 0 PPV price in cents
userIds array of integers [] target specific fans
userLists array of strings [] target list ids
curl -X POST https://api.ofapis.com/api/public/v1/accounts/42/scheduled/messages \
  -H "Authorization: Bearer ofapis_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"text":"Friday drop is live","price":0,
       "userLists":["456"],"scheduledAt":"2026-03-14T18:05:00Z"}'
{ "data": { "id": 555666777, "date": "2025-07-01T12:00:00+00:00", "text": "Hey!",
            "isReady": false, "total": 0, "pending": 0 } }

userLists takes the string ids from the fan segments API. The returned id is the queueId — the handle you need to cancel, and a different namespace from a post's postId. total and pending start at zero and fill in as the queue materialises.

One caveat to be deliberate about: the endpoint description says userIds and userLists can be combined, while the request model's own notes call them mutually exclusive. Until that is settled, send one or the other.

Cancelling and rescheduling

There is no PATCH. Delete and recreate — in a Node client, that is two calls in a fixed order.

async function reschedule(accountId, postId, mediaFiles, when) {
  await fetch(`${base}/accounts/${accountId}/scheduled/posts/${postId}`,
              { method: "DELETE", headers });                    // 1 credit
  const res = await fetch(`${base}/accounts/${accountId}/scheduled/posts`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json",
               "Idempotency-Key": `post-${postId}-${when}` },
    body: JSON.stringify({ mediaFiles, isScheduled: 1, scheduledAt: when }),
  });                                                            // 3 credits
  return (await res.json()).data.id;                             // new postId
}

Delete the post first: if the create fails validation you have not left a duplicate in the queue. DELETE /scheduled/posts/{postId} returns {"data":null}; DELETE /scheduled/messages/{queueId} returns {"data":{"success":true,"queue":null}}.

Cost

Creates are the expensive calls in this group, and the only ones in the public API above the default weight apart from media upload.

Call Credits
GET /scheduled/posts 1
GET /scheduled/posts/counters 1
POST /scheduled/posts 3
POST /scheduled/messages 3
DELETE either queue 1

So a reschedule is 4 credits, not 2 — worth knowing before you let a content automation shift a month's calendar in bulk. Both POSTs accept Idempotency-Key, and a replay returns the original response with Idempotency-Replayed: true without charging again; a concurrent duplicate gets 409 IDEMPOTENCY_CONFLICT, which is refunded. Plan ceilings are on pricing and the per-minute limits on rate limits.

FAQ

Why does listing scheduled posts return a 400?

Because publishDate and publishDateEnd are both required query parameters and there is no default window. Malformed or missing query parameters are rejected as a plain 400 string rather than the usual error envelope — see API errors.

How do I queue a post with no fixed date?

Send isScheduled: 0 and omit scheduledAt. The item joins the "publish later" queue instead of being bound to a timestamp, and the future-date validation is skipped entirely.

Why does a scheduled posts query also return messages?

Because each item carries a type field of post or message, and the read spans both queues for the window. Filter on type client-side if your calendar shows them in separate lanes.

Can I move a scheduled item to a new time?

Only by deleting it and creating a replacement — there is no update route. Budget 4 credits per move (1 to delete, 3 to create) and reuse the old id in your Idempotency-Key so a retried shift cannot double-book the slot.

Which timezone should I send?

Send scheduledAt as a UTC instant ending in Z; it is parsed strictly and a naive string is rejected. The separate timeZone parameter only affects how the read endpoints bucket items into calendar days, and defaults to UTC.

Get a free token · Scheduling guide · Browse the full API

Related guides

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