ofapisv1
quickstart

Updated

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.

Two things about that query string. limit defaults to 20 and the server clamps it to 1–100, so the 50 is honoured and a 500 is not; read page.list().size() rather than trusting the number you sent. And nextOffset is an int in the record above because the API sends one — do not substitute offset += 50, which desynchronises the moment a page comes back short. The list endpoints default to 10 instead of 20, and reading one conversation does not accept offset at all: GET /chats/{chatId}/messages pages backwards on an olderThanMessageId cursor, so Page<T> is the wrong record for it.

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));
HttpRequest request = req("/chats/" + chatId + "/messages")
        // Every POST accepts this. Derive it from what the send *is* — the drop
        // and the recipient — so every attempt, in every JVM, reuses one key.
        .header("Idempotency-Key", "drop-2026-03:" + chatId)
        .POST(HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(dm)))
        .build();

var res = sendRetrying(http, request, 4);

switch (res.statusCode()) {
    case 200 -> System.out.println("sent; this path is weight 1");
    case 401 -> throw new IllegalStateException("UNAUTHENTICATED — key revoked, nothing debited");
    case 402 -> throw new IllegalStateException("INSUFFICIENT_CREDITS — nothing debited");
    // IDEMPOTENCY_CONFLICT: a duplicate with this key is still in flight. It is
    // debited and then refunded. Read the chat back; do not send again.
    case 409 -> System.out.println("duplicate in flight for " + chatId);
    case 422, 424 -> throw new IllegalStateException("account not linked, or its session expired");
    case 422 -> throw new IllegalStateException("ACCOUNT_NOT_ACTIVE — and you paid for it");
    default  -> throw ApiException.of(res);
}

HttpRequest is immutable, which is exactly what a retry loop wants: build it once and hand the same instance back to send as many times as you need. The loop below retries only what is free to retry — a 429 is refused before metering and a 5xx is debited and then refunded, so neither costs anything the second time around:

static Duration retryDelay(HttpResponse<?> res, int attempt) {
    return res.headers().firstValue("Retry-After")
              .map(Long::parseLong)
              .map(Duration::ofSeconds)
              .orElseGet(() -> Duration.ofSeconds(1L << attempt));   // 1s, 2s, 4s
}

static HttpResponse<String> sendRetrying(HttpClient http, HttpRequest req, int attempts)
        throws IOException, InterruptedException {
    for (int attempt = 0; ; attempt++) {
        HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
        int status = res.statusCode();
        boolean freeToRepeat = status == 429 || status >= 500;
        if (!freeToRepeat || attempt == attempts - 1) return res;
        Thread.sleep(retryDelay(res, attempt).toMillis());
    }
}

The 422 arm is there as a reminder, because the usual mental model is wrong. Credits are debited before the handler runs. 401, 402, 403 and 429 are refused before metering and cost nothing; 409, 424 and any 5xx are debited and then refunded; 400, 404, 413, 415 and 422 are debited and stay debited. A loop that resends a malformed body four times pays four times and still fails. Nor is the weight always 1 — POST /accounts/{id}/media is 5, the two scheduled/ creates are 3, and the account-scoped send is 2, which is why the fan-out below is more expensive per message than the single-account call above. Failure codes are catalogued in the error reference and the weights in API cost.

Fanning out across accounts

Agencies run the same call for every creator on the roster 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.

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