OnlyFans API with Java — records, one HttpClient, many accounts
Most of the work in wiring the OnlyFans API into a Java service is not the HTTP — it is deciding what the JSON deserialises into. ofapis wraps every success in a {"data": ...} envelope, which in Java means one generic carrier record and a TypeReference at every call site. Get that right and the rest is one HttpClient and some CompletableFuture plumbing. ofapis is an independent service, not built or endorsed by OnlyFans; creator sessions are refreshed on our side, so your JVM only ever holds an ofapis_sk_... key.
Build setup
The transport is in the JDK — java.net.http shipped in Java 11 — so the only dependency is a JSON binder. Jackson 2.15+ handles records natively.
<!-- pom.xml -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
// build.gradle
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
Mint a key in the dashboard (start free) and read it from the environment or your secrets manager — see authentication.
The envelope, as records
Three records cover almost the whole surface. ApiResponse<T> is the envelope, Page<T> is the shape of every paginated list, and the rest are payloads:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ApiResponse<T>(T data) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Page<T>(List<T> list, boolean hasMore, int nextOffset) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Me(long id, String username, String name, String email,
int subscribersCount, boolean isAuth) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Chat(WithUser withUser, int unreadMessagesCount, boolean canSendMessage) {
public record WithUser(long id, String name, String username, String avatar) {}
}
Annotate for unknown fields: new response fields ship without a major version bump, and an unannotated record throws UnrecognizedPropertyException on the next deploy.
Now the Java-only wrinkle. ApiResponse<Me>.class does not exist — erasure drops the parameter, so readValue(body, ApiResponse.class) puts a LinkedHashMap in data() and a ClassCastException a few lines later. Use TypeReference, whose anonymous subclass keeps the parameter in the class file:
static final ObjectMapper JSON = new ObjectMapper();
static <T> T unwrap(String body, TypeReference<ApiResponse<T>> type) throws IOException {
return JSON.readValue(body, type).data();
}
Me me = unwrap(res.body(), new TypeReference<ApiResponse<Me>>() {});
The trailing {} is not optional — it is what creates the subclass. Declare the common ones as static final constants; mapper.getTypeFactory().constructParametricType(...) is the alternative when the type is only known at runtime.
One client, shared
HttpClient is immutable and thread-safe. Build it once, hold it in a field or a singleton bean, and let it pool connections; a client per request leaks selector threads and defeats keep-alive.
public final class Ofapis {
private static final String BASE = "https://api.ofapis.com/api/public/v1";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final String token = System.getenv("OFAPIS_KEY");
private HttpRequest.Builder req(String path) {
return HttpRequest.newBuilder(URI.create(BASE + path))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json");
}
public <T> T get(String path, TypeReference<ApiResponse<T>> type) throws Exception {
HttpResponse<String> res = http.send(req(path).GET().build(),
HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 200) throw ApiException.of(res);
return unwrap(res.body(), type);
}
}
connectTimeout on the client and timeout on the request are separate knobs — set both, or a stalled read hangs a worker indefinitely.
Paging then reads as a loop over Page<T>:
static final TypeReference<ApiResponse<Page<Chat>>> CHATS = new TypeReference<>() {};
int offset = 0;
while (true) {
Page<Chat> page = api.get("/chats?limit=50&offset=" + offset, CHATS);
page.list().forEach(c -> System.out.println(c.withUser().username()));
if (!page.hasMore()) break;
offset = page.nextOffset();
}
Chats are keyed by withUser().id() — that is the id you post to.
Sending, and the price trap
price is an integer count of cents. Bind it to int or long; a double field will happily round 12.99 into an off-by-a-cent charge on a paid unlock, and Jackson will not warn you.
public record NewMessage(String text, List<Long> mediaFiles, int price,
boolean lockedText, List<Long> previews) {}
var dm = new NewMessage("New set is live", List.of(99123L), 1299, true, List.of(99123L));
var res = http.send(
req("/chats/" + chatId + "/messages")
.POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(dm)))
.build(),
HttpResponse.BodyHandlers.ofString());
switch (res.statusCode()) {
case 200 -> System.out.println("sent, one credit billed");
case 401 -> throw new IllegalStateException("UNAUTHENTICATED — key revoked");
case 402 -> throw new IllegalStateException("INSUFFICIENT_CREDITS");
case 424 -> throw new IllegalStateException("ACCOUNT_NOT_LINKED / OF_SESSION_EXPIRED");
case 429 -> retryAfter(res); // Retry-After header, in seconds
default -> throw ApiException.of(res);
}
Only 2xx responses draw a credit, so a rejected or throttled call costs nothing and is free to retry. Failure codes are catalogued in the error reference.
Fanning out across accounts
Agencies run the same call for every creator under /accounts/{accountId}/.... Two idioms, depending on your JDK. On 11–20, sendAsync composes into CompletableFuture:
List<CompletableFuture<Page<Chat>>> calls = accountIds.stream()
.map(id -> http.sendAsync(req("/accounts/" + id + "/chats?limit=50").GET().build(),
HttpResponse.BodyHandlers.ofString())
.thenApply(r -> unwrapUnchecked(r.body(), CHATS)))
.toList();
CompletableFuture.allOf(calls.toArray(CompletableFuture[]::new)).join();
On Java 21+ virtual threads make the blocking version the simpler one — one thread per account, no pool to size:
try (var exec = Executors.newVirtualThreadPerTaskExecutor()) {
accountIds.forEach(id -> exec.submit(() -> sweep(id)));
} // close() waits for every task
Both will outrun your plan: 60 rpm on Free, 100 on Starter, 500 on Pro. Virtual threads are cheap enough that ten thousand of them will cheerfully collect ten thousand 429s, so gate the fan-out with a Semaphore sized to your ceiling and read X-RateLimit-Remaining off each response. Details in rate limits and agency automation.
FAQ
Why does Jackson return a LinkedHashMap instead of my record?
Type erasure. ApiResponse.class carries no information about T, so Jackson binds data to a generic map. Pass new TypeReference<ApiResponse<Me>>() {} — the anonymous subclass preserves the parameter — or build a JavaType with constructParametricType.
Which Java version do I need?
Java 11 for java.net.http.HttpClient, 16 for records, 21 for virtual threads. On Java 8 the client is unavailable and you will need OkHttp or Apache HttpClient plus classes instead of records; everything else on this page carries over.
Should I use OkHttp instead of the JDK client?
The built-in client is enough for JSON over HTTPS and adds no dependency. OkHttp earns its place when you want interceptors for retry and logging, connection-pool tuning, or you are on Android, where java.net.http is not available.
Do I have to close HttpClient?
Not before Java 21. From 21 it implements AutoCloseable, but the intended lifetime is still application-wide — close it at shutdown, never per request. The instance is immutable and safe to share across threads.
How do I store the price field?
As int cents. 1299 is $12.99. Never double or float; if you carry money through your own domain model, use long cents or BigDecimal and convert at the edge, right before serialising the request body.