How to Schedule OnlyFans Posts Programmatically
Once the first scheduled post works, the interesting problem stops being "how do I create one" and becomes "how do I keep a remote queue in sync with my calendar without re-reading it every minute". This guide treats the ofapis scheduled queue as what it is — a write-mostly collection you can only read back inside a date window — and covers the create, the read, the cheap change detector, and what each edit costs. Where a content queue sits among the other jobs worth handing to code is mapped out in the OnlyFans automation guide.
The queue has four operations, not five
There is no update. The whole surface is:
| Operation | Route | Credits |
|---|---|---|
| Create | POST /accounts/{id}/scheduled/posts |
3 |
| Read a window | GET /accounts/{id}/scheduled/posts |
1 |
| Per-day counts | GET /accounts/{id}/scheduled/posts/counters |
1 |
| Delete | DELETE /accounts/{id}/scheduled/posts/{postId} |
1 |
The missing PATCH is the single fact that shapes everything else. Moving a post from Tuesday to Thursday is a delete plus a create: 4 credits and a brand-new post id, which means any id you cached is now dangling. Design your local model so the ofapis post id is a derived value you can throw away, keyed on something you own — a row id in your own calendar table — rather than the thing your calendar is keyed by.
Creating: three credits, and isScheduled decides what scheduledAt means
The create body is mediaFiles, isScheduled, isPublishedWithPeriod and scheduledAt. There is no text field; a caption belongs to a scheduled message, not a scheduled 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: calendar-row-7741" \
-d '{"mediaFiles":[88123],"isScheduled":1,"scheduledAt":"2026-06-20T18:00:00Z"}'
With isScheduled: 1 the API requires scheduledAt and parses it as an ISO-8601 instant that must be strictly in the future; anything else is rejected with 400 BAD_REQUEST. With isScheduled: 0 the post goes into the "publish for later" queue and no date is needed. A success returns {"data":{"id":222333444}}.
That 400 matters more than it looks. Credits are debited before the handler runs, and a 400 is not refunded — so a loop that sends a timestamp one second in the past burns 3 credits per attempt and produces nothing. Compare against now in your own code before you send, and send the Idempotency-Key shown above: a replay returns the original body with Idempotency-Replayed: true and is not charged a second time, which is what keeps a retried deploy from laying down duplicate posts at 3 credits each. The media upload that produced 88123 is itself 5 credits, so a post with fresh media is really an 8-credit object.
Reading: the window is mandatory
GET /accounts/{id}/scheduled/posts is not a plain list. It requires publishDate and publishDateEnd as YYYY-MM-DD strings, and takes timeZone as a TZ database name (UTC by default) and limit, which defaults to 20 and is clamped to 1–100.
curl -G https://api.ofapis.com/api/public/v1/accounts/42/scheduled/posts \
-H "Authorization: Bearer ofapis_sk_..." \
--data-urlencode "publishDate=2026-06-01" \
--data-urlencode "publishDateEnd=2026-06-30" \
--data-urlencode "timeZone=America/New_York" \
--data-urlencode "limit=100"
{"data":{"list":[{"id":111,"type":"message","publishDateTime":"2026-06-01T12:00:00+00:00",
"createdDateTime":"2026-05-10T09:00:00+00:00"}],"syncInProcess":false}}
Two things to build around. There is no offset on this route, so a window holding more than 100 items cannot be paged — you narrow the window instead, which is why a weekly read is the practical unit rather than a monthly one. And syncInProcess is real: when it comes back true the upstream calendar is still syncing and a short or empty list is not evidence that the queue is empty. Treat a true as "no answer yet" and re-read, rather than reconciling against it and deleting posts that were simply not returned.
Note the type field. The window mixes scheduled posts and scheduled messages, so filter on it before you decide anything.
Counters as a change detector
Polling the full window hourly costs a credit each time and returns the same payload almost always. The counters route takes the same publishDate, publishDateEnd and timeZone and returns a per-day tally instead:
{"data":{"list":{"2026-06-01":{"message":3,"post":1},"2026-06-02":{"post":2}},"syncInProcess":false}}
Also 1 credit, but far smaller and comparable with a hash. The pattern is: poll counters, diff the map against the last one you stored, and only spend a second credit on the full window when a day's post count moves. A fifteen-minute reconciler costs 4 × 24 × 30 ≈ 2,880 credits a month against the Starter plan's 30,000 — about a tenth of the budget doing nothing. Hourly brings that to 720. Pick the interval from the diff rate you actually observe, not from habit; credit pricing is where this shows up.
import hashlib, json, requests
BASE = "https://api.ofapis.com/api/public/v1/accounts/42/scheduled"
H = {"Authorization": "Bearer ofapis_sk_..."}
def counters(start, end, tz="UTC"):
r = requests.get(f"{BASE}/posts/counters", headers=H,
params={"publishDate": start, "publishDateEnd": end, "timeZone": tz})
r.raise_for_status()
return r.json()["data"]
def changed_days(start, end, last_hashes):
body = counters(start, end)
if body.get("syncInProcess"):
return None, last_hashes # upstream mid-sync: no verdict this tick
fresh = {day: hashlib.sha1(json.dumps(v, sort_keys=True).encode()).hexdigest()
for day, v in body["list"].items()}
moved = [d for d, h in fresh.items() if last_hashes.get(d) != h]
moved += [d for d in last_hashes if d not in fresh] # day emptied out
return moved, fresh
Returning None on syncInProcess rather than an empty list is the part that saves you: a reconciler that reads "nothing changed" out of a partial sync will happily re-create posts that already exist. The session, retry and envelope handling the snippet leaves out are in the Python client guide.
Posts and messages are different queues
They share the counters map and the read window, and nothing else. A scheduled post takes mediaFiles and publishes to your feed. A scheduled message — POST /accounts/{id}/scheduled/messages, also 3 credits — takes text, mediaFiles, a price in cents, and targeting via userIds or userLists, and returns {"id":555666777,"date":"...","text":"Hey!","isReady":false,"total":0,"pending":0}. Deletes go to different routes, /posts/{postId} against an OnlyFans post id and /messages/{queueId} against a queue id, and the two id spaces are not interchangeable. If you are scheduling a mass DM for a future date, this is the route for it, not the feed queue. Fuller shapes are in the scheduling API reference and the API docs.
What breaks at 3am
A 424 OF_SESSION_EXPIRED means the stored OnlyFans session for that account died; the credits are refunded, but every subsequent create will fail the same way until the account is re-linked, so a scheduler should stop and alert rather than retry on a timer. A 429 is free and carries Retry-After — honour it rather than guessing, since the ceiling is 60 requests per minute on Free, 100 on Starter and 500 on Pro, per token per minute. A 404 ACCOUNT_NOT_FOUND, by contrast, is charged, so a misconfigured account id in a cron loop is a slow leak rather than a loud failure. The general error and retry rules apply here unchanged. With the feed queue running on a clock, the next step in this series is the other half of outbound — automating DMs to a segment, where the bill is per recipient rather than per call.
FAQ
Can I move a scheduled post to a different time?
No — there is no update route. You delete the existing post by id (1 credit) and create a replacement (3 credits), which returns a new id. Budget 4 credits per reschedule and keep your own calendar row, not the ofapis post id, as the stable key.
Why does the list endpoint require a date range?
Because it reads a calendar window upstream rather than a flat collection. publishDate and publishDateEnd are both required YYYY-MM-DD values, and since the route has no offset parameter and clamps limit at 100, a window is also the only way to page — narrow the dates instead of asking for more rows.
What does syncInProcess mean on a read?
It means the upstream calendar is still syncing and the list you got back may be incomplete. Do not reconcile against it. Skip the tick and read again, otherwise a reconciler can conclude a day is empty and re-create posts that already exist.
How much does a month of scheduling actually cost?
Thirty posts cost 90 credits to lay down, plus 5 per media upload if the files are new. The ongoing cost is whatever your reconciler spends: a counters poll is 1 credit, so hourly is about 720 credits a month and every-fifteen-minutes is about 2,880.
Do scheduled posts and scheduled messages share an id space?
No. A scheduled post is deleted at /scheduled/posts/{postId} using an OnlyFans post id; a scheduled message is deleted at /scheduled/messages/{queueId} using a queue id. They appear together in the counters map under the post and message keys, which is the only place the two queues overlap.