ofapisv1
quickstart

Updated

OnlyFans API with curl: auditing an account from the shell

Sometimes the question is not "how do I build this" but "what is actually in there right now" — how many active subscribers, which of them lapsed, how close the token is to its ceiling, whether the link is still healthy. That is a thirty-minute terminal session, not a project. This page walks one end to end with nothing but curl, jq and a shell. ofapis is an independent, unofficial API for OnlyFans; the creator session lives server-side, so the only secret on your machine is an ofapis_sk_... key (start free, authentication).

Put the token in a file, not in the command line

Arguments are visible to every user on the box through ps, and they end up in your shell history. curl can read headers from a file instead, which fixes both:

umask 077
read -rs OFAPIS_KEY                                      # paste it; nothing is echoed
printf 'Authorization: Bearer %s\n' "$OFAPIS_KEY" > "$HOME/.ofapis-auth"
unset OFAPIS_KEY

BASE=https://api.ofapis.com/api/public/v1
of() { curl -sS -H @"$HOME/.ofapis-auth" "$@"; }

-H @file reads one header per line and has been in curl since 7.55. -sS silences the progress meter but keeps real errors on stderr — plain -s hides a DNS failure as effectively as it hides the meter.

One call, and the status code with it

of -o /tmp/me.json -w 'http=%{http_code} ttfb=%{time_starttransfer}s\n' "$BASE/me"
jq '.data | {id, username, subscribersCount}' /tmp/me.json
http=200 ttfb=0.84s
{ "id": 12345678, "username": "creator_handle", "subscribersCount": 4120 }

-w writes its template after the transfer, so the body can go to a file and the metadata to your terminal in the same invocation. Reach for --fail-with-body rather than the older -f: both make curl exit non-zero on a 4xx or 5xx, but -f throws the body away, and the body is where {"error":{"code","message"}} lives. What each code means is what decides whether repeating the call can ever work. Every success is wrapped in data; nothing is ever returned bare.

Read the budget headers

of -D /tmp/h.txt -o /dev/null "$BASE/notifications/count"
grep -i '^x-ratelimit\|^x-request-id' /tmp/h.txt
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 58
X-RateLimit-Reset: 1749600000
X-Request-Id: 8f1c2b7e-...

X-RateLimit-Reset is an absolute unix second — the end of the current one-minute window, not a countdown — so date -d @1749600000 tells you when the allowance comes back. The limit is per token per minute and follows the plan: 60 on Free, 100 on Starter, 500 on Pro (rate limits). X-Request-Id is echoed from your request if you send one and generated otherwise; capture it before you report anything odd.

Page the roster into a file

offset=0
: > /tmp/active.ndjson
while :; do
  of "$BASE/subscribers?type=active&limit=100&offset=$offset" > /tmp/page.json
  jq -c '.data.list[]' /tmp/page.json >> /tmp/active.ndjson
  [ "$(jq -r '.data.hasMore' /tmp/page.json)" = true ] || break
  offset=$(jq -r '.data.nextOffset' /tmp/page.json)
done
wc -l < /tmp/active.ndjson

limit defaults to 20 and the server clamps it to 1–100, so there is no point asking for 500 — and because the clamp is silent, take the next page's start from .data.nextOffset instead of adding your own step. type accepts active, expired or all; anything else is treated as active rather than rejected.

Now the audit proper. A subscriber record holds id, username, name, avatar and a subscribedOn flag — there is no spend or lifetime-value field on it, so churn is the thing this data can actually answer:

jq -r '.username' /tmp/active.ndjson | sort -u > /tmp/a.txt
of "$BASE/subscribers?type=expired&limit=100" \
  | jq -r '.data.list[].username' | sort -u > /tmp/e.txt
comm -12 /tmp/a.txt /tmp/e.txt | head          # resubscribed after lapsing
comm -13 /tmp/a.txt /tmp/e.txt | wc -l         # gone and not back

The same .data.list[] / .data.hasMore pattern covers /chats, /following and /mentions. /lists is the exception worth knowing: it and /lists/{listId}/users default to a limit of 10, not 20 (lists endpoint).

Retries that wait the right amount

of --retry 5 --retry-all-errors --retry-max-time 120 \
   --fail-with-body -o /tmp/chats.json \
   -w '%{http_code}\n' "$BASE/chats?limit=100"

Since 7.66 curl reads Retry-After and sleeps for exactly that long instead of its own doubling schedule, which is what you want: a 429 from this API always carries the header, in seconds. --retry-all-errors (7.71) widens retries past the transient HTTP codes to connection-level failures, and --retry-max-time caps the whole affair so a script cannot sit there for an hour. Do not add --retry to a POST without the next section.

A POST you can run twice on purpose

key="audit-ping-$(date -u +%Y%m%d)"
of -X POST "$BASE/chats/98765/messages" \
   -H 'Content-Type: application/json' \
   -H "Idempotency-Key: $key" \
   --data-binary @body.json \
   -D /tmp/ph.txt -o /tmp/post.json -w '%{http_code}\n'
grep -i '^idempotency-replayed' /tmp/ph.txt

Two curl details first: -d implies POST but also sets Content-Type: application/x-www-form-urlencoded, so the JSON header is not optional, and -d strips newlines from a file while --data-binary sends it byte for byte.

Then the useful part. Run that command again with the same key inside 24 hours and you get the original response back verbatim with Idempotency-Replayed: true, charged once. Two terminals racing the same key produce one send and one 409 IDEMPOTENCY_CONFLICT. The record is matched on the token and the key alone — not on the URL or the body — so changing body.json and reusing the key replays the old message instead of sending the new one. That is the behaviour that makes --retry safe on a write.

What the session cost

Call Credits
GET /me, /chats, /subscribers, /notifications (per page) 1
POST /chats/{chatId}/messages 1
POST /accounts/{id}/chats/{chatId}/messages 2
POST /accounts/{id}/scheduled/posts or /scheduled/messages 3
POST /accounts/{id}/media 5

Credits are debited before the handler runs, and that decides what a failure costs you. A 401, 402, 403 or 429 is refused ahead of metering and is free. A 409, a 424 or any 5xx is debited and then refunded. A 400, 404, 413, 415 or 422 is charged and not refunded — a typo in an account id is a credit you do not get back, which is a real argument for checking a path against the live docs before looping it. The audit above — one /me, 42 pages of subscribers, one page of chats — is 44 credits, inside even a Free key's 250. Per-endpoint weights are listed under API cost.

FAQ

How do I keep my ofapis token out of ps and shell history?

Write it once to a file with umask 077 and pass it as -H @/path/to/file, so the token is never an argument. Reading it with read -rs keeps it off the screen, and unsetting the variable afterwards keeps it out of the environment of anything you launch next. An exported variable interpolated into the command line, by contrast, shows up in ps for anyone watching.

Does curl --retry honour the Retry-After header?

Yes, from curl 7.66 onward: when a 429 or 503 carries Retry-After, curl waits that many seconds instead of using its own exponential backoff, bounded by --retry-max-time if you set one. Every 429 from this API includes the header, so --retry 5 --retry-max-time 120 behaves sensibly out of the box on reads.

How do I see both the response body and the status code in one call?

Send the body to a file with -o and print the code with -w '%{http_code}\n', or use --fail-with-body so curl exits non-zero while still writing the error envelope to stdout. -D file captures the response headers alongside, which is how you get X-RateLimit-Remaining and X-Request-Id without a second request.

Can I safely re-run a POST from the shell?

Yes, if it carries an Idempotency-Key. A repeat within 24 hours returns the stored response with Idempotency-Replayed: true and is charged once; a concurrent duplicate gets 409 IDEMPOTENCY_CONFLICT. Keys are scoped to the API token, so two different tokens using the same string do not collide — and one token reusing a key with a different body replays the first result.

Is curl enough, or do I need Postman?

curl is enough for everything on this page, and it is what a reproducible bug report should contain. A collection is nicer when several people are poking at the same account or when you want saved environments and variables — there is a ready-made one in the Postman integration, and the schema behind both is published in the API documentation. If what you are sketching is a whole workflow rather than one request, a hosted Pipedream step runs the same calls with nothing installed locally.

Same quickstart, another language

Related guides

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