OnlyFans API in Postman
ofapis publishes a machine-readable OpenAPI 3 document, and the reference UI at /docs is rendered from that same document rather than hand-written alongside it. The practical consequence for Postman: you never build a request by hand. Import the spec, let Postman generate the collection, and the only work left is auth, scripting and the runner. One thing to know before the first Send — this is a metered API, and a wrong guess is not always free.
Import the spec
In Postman, Import → Link, and paste:
https://api.ofapis.com/v3/api-docs/public
Postman detects OpenAPI 3.0 and offers to generate a collection. Accept it. The site proxies the identical document same-origin at https://ofapis.com/docs/openapi, so either URL imports the same thing.
Two details catch people out:
- The
baseUrlvariable is the host, not the versioned base. The spec declares one server,https://api.ofapis.com, and carries the prefix inside every path, so a generated request reads{{baseUrl}}/api/public/v1/subscribers. Do not strip the prefix out of the paths. - Folders come from OpenAPI tags, not URL shape. You get Me, Chats, Users, Subscribers, Following, Vault, Media, Notifications and Mentions, plus lists, mailings and scheduled. Most real routes are account-scoped —
/accounts/{accountId}/chats— so the folder says nothing about how many path variables a request needs.
The security scheme is HTTP Bearer, named ApiToken, so Postman imports collection-level Bearer auth with an empty token field.
One token, stored as a secret
Create an environment, ofapis prod, with two variables:
| Variable | Type | Current value |
|---|---|---|
ofapis_token |
secret | ofapis_sk_… |
accountId |
default | 42 |
Fill the Current value column and leave Initial value empty: Postman syncs and exports initial values, current values stay on your machine, and the secret type masks the field in the UI. Then set the collection's Authorization to Bearer Token → {{ofapis_token}} and leave each request on Inherit auth from parent. That is the whole of authentication — one header, no signing, no OAuth redirect to register. accountId earns its place because scoped routes differ only by that number, so swapping creators is an environment switch rather than a find-and-replace.
Exploring blind is charged
Credits are debited before the handler runs, and whether you get them back depends on the status:
| Outcome | Billed? |
|---|---|
401 UNAUTHENTICATED, 402 INSUFFICIENT_CREDITS, 403 NO_ACTIVE_SUBSCRIPTION, 429 RATE_LIMIT_EXCEEDED |
Refused before metering — free |
409 IDEMPOTENCY_CONFLICT, 424 (expired OnlyFans session), any 5xx |
Debited, then refunded |
400 BAD_REQUEST, 404 ACCOUNT_NOT_FOUND, 413, 415, 422 (account not linked) |
Charged, not refunded |
The failure modes that cost money are the ones you hit while poking around: a mistyped query parameter, a missing required one, an account id that is not yours. GET /accounts/{accountId}/scheduled/posts is the sharpest edge — publishDate and publishDateEnd are both required, and omitting either buys a 400.
Weights are not flat either. Most calls are 1 credit; POST /accounts/{id}/media is 5, scheduled posts and messages are 3 each, and the account-scoped POST /accounts/{id}/chats/{chatId}/messages is 2. Read a request's parameter descriptions before Send — the Free plan's 250 credits do not survive much trial and error. See API cost and error codes.
Pre-request script: an idempotency key per POST
Any POST accepts an Idempotency-Key. A replay returns the original response with Idempotency-Replayed: true and is not charged twice; a concurrent duplicate gets 409. At collection level, Scripts → Pre-request:
if (pm.request.method === "POST") {
const key = pm.collectionVariables.get("idem_key")
|| pm.variables.replaceIn("{{$guid}}");
pm.collectionVariables.set("idem_key", key);
pm.request.headers.upsert({ key: "Idempotency-Key", value: key });
}
Holding the key in a collection variable rather than minting a fresh {{$guid}} per send is the point: hit Send twice on a mailing trigger because the first response was slow, and the second call replays instead of firing again. Clear idem_key deliberately when you want a new operation.
Tests that read the envelope
Every success body is {"data": …} and every error body is {"error": {"code", "message"}}, so assertions start one level in. On a paginated GET, Scripts → Post-response:
pm.test("200 with data envelope", () => {
pm.response.to.have.status(200);
pm.expect(pm.response.json()).to.have.property("data");
});
pm.test("rate-limit budget left", () => {
const left = Number(pm.response.headers.get("X-RateLimit-Remaining"));
pm.expect(left).to.be.above(0);
});
const page = pm.response.json().data;
if (page.hasMore) {
pm.collectionVariables.set("offset", page.nextOffset);
postman.setNextRequest(pm.info.requestName);
} else {
pm.collectionVariables.set("offset", 0);
}
X-RateLimit-Limit, -Remaining and -Reset (unix seconds) ride on every response; a 429 adds Retry-After. X-Request-Id is echoed from your request or generated server-side — capture it in a test and you have something to quote in support.
Walk a paginated folder with the Collection Runner
Reads take limit (default 20, clamped by the server to 1–100) and offset (default 0). Two exceptions are worth memorising: /accounts/{id}/lists and /accounts/{id}/lists/{listId}/users default to limit=10, and chat message history pages on the cursor olderThanMessageId instead of an offset.
Make a folder holding a single request:
GET {{baseUrl}}/api/public/v1/accounts/{{accountId}}/chats?limit=100&offset={{offset}}
Seed offset to 0, select the folder, and Run folder. The setNextRequest call above points the request at itself, so one iteration walks the whole set. Set Delay from your plan's per-token-per-minute ceiling — 60 on Free, 100 on Starter, 500 on Pro — so roughly 1100, 600 and 120 ms. More in rate limits.
Add a stop, because a self-referencing setNextRequest with a buggy hasMore check burns a credit a page indefinitely:
const n = Number(pm.collectionVariables.get("pages") || 0) + 1;
pm.collectionVariables.set("pages", n);
if (n > 25) postman.setNextRequest(null);
Export the run as JSON from the runner summary when you want the pages themselves rather than a pass/fail. On the chat endpoints, chatId is the fan's OnlyFans user id, so the ids collected here are reusable as path variables.
When exploration is done, the </> panel converts any request to curl, Node, Python or Go. One warning: the generator resolves variables, so the snippet carries the literal ofapis_sk_… from your environment, not {{ofapis_token}}. Swap in an environment lookup before it reaches a repo; the curl reference shows these calls already written that way, and the Node.js version of these calls and the Go version add the retry and error handling the generator leaves out.
If the collection is a prototype for something that will run on a schedule rather than code you intend to maintain, the request you just got working transfers unchanged into an n8n HTTP Request node or a Power Automate HTTP action — same URL, same single header, same envelope to unwrap.
FAQ
Which URL do I import to get the collection?
Import https://api.ofapis.com/v3/api-docs/public as a link. That is the OpenAPI 3 document for the public API group, and the same one the /docs reference renders, proxied there as https://ofapis.com/docs/openapi.
Why does the imported base URL not include /api/public/v1?
Because the spec declares one server, https://api.ofapis.com, and keeps the version prefix inside each path entry. Postman turns the server into {{baseUrl}} and leaves /api/public/v1/... in the request URL. Append the prefix to baseUrl as well and you send it twice, for a 404.
Does clicking around in Postman cost credits?
Yes, and not every failure is refunded. 401, 402, 403 and 429 are refused before metering and cost nothing, while 409, 424 and 5xx are debited then refunded — but 400, 404, 413, 415 and 422 stay charged. Budget a handful of wasted credits while you learn the parameter shapes.
How do I keep the token out of a shared collection?
Put it in an environment variable of type secret and fill only the Current value column. Postman exports and syncs initial values, never current ones, so the exported JSON ships {{ofapis_token}} and nothing else. The code generator does resolve the value, so scrub generated snippets.
Can the Collection Runner page through a whole endpoint?
Yes. Capture data.nextOffset into a collection variable in a post-response script, call postman.setNextRequest on the request's own name while data.hasMore is true, and run the folder once. Add a page cap so a bad condition cannot loop forever at a credit a page.
Is this the official OnlyFans Postman collection?
No. ofapis is an independent, unofficial API, not affiliated with OnlyFans. The collection is generated from the ofapis OpenAPI document and describes ofapis routes only; the integrations catalogue covers the same API from other tools.
Related: curl · API documentation · all integrations