ofapisv1
quickstart

Updated

OnlyFans API with PHP

Good OnlyFans API PHP code settles two questions early: what the JSON becomes once it lands in your process, and which layer owns retries. Below that means readonly DTOs over the response envelope, a Guzzle handler stack under them, and the Laravel equivalent. ofapis is an independent service, not run by or affiliated with OnlyFans; an account is linked once in the dashboard and refreshed server-side, so the only secret your process holds is an ofapis_sk_... key (sign-up, authentication).

Give the envelope a type

Successes are wrapped in {"data": ...} and lists nest again under list, so $json['data']['list'][0]['withUser']['name'] works right up until a key moves. Promoted readonly properties plus a static fromArray() pin the shape in one file:

<?php
declare(strict_types=1);

namespace App\Ofapis;

final readonly class Me
{
    public function __construct(
        public int $id,
        public string $username,
        public string $name,
        public ?string $email,
        public int $subscribersCount,
        public bool $isAuth,
    ) {}

    public static function fromArray(array $d): self
    {
        return new self(
            id:               (int) $d['id'],
            username:         $d['username'],
            name:             $d['name'] ?? $d['username'],
            email:            $d['email'] ?? null,
            subscribersCount: (int) ($d['subscribersCount'] ?? 0),
            isAuth:           (bool) ($d['isAuth'] ?? false),
        );
    }
}

final readonly class Chat
{
    public function __construct(
        public int $withUserId,      // chats are keyed by withUser.id
        public string $name,
        public int $unreadMessagesCount,
        public bool $canSendMessage,
        public ?string $lastMessageText,
    ) {}

    public static function fromArray(array $d): self
    {
        return new self(
            withUserId:          (int) $d['withUser']['id'],
            name:                $d['withUser']['name'] ?? $d['withUser']['username'],
            unreadMessagesCount: (int) ($d['unreadMessagesCount'] ?? 0),
            canSendMessage:      (bool) ($d['canSendMessage'] ?? false),
            lastMessageText:     $d['lastMessage']['text'] ?? null,
        );
    }
}

final readonly class needs PHP 8.2; on 8.1, drop the class-level keyword and mark each promoted property public readonly int $id. Note canSendMessage — a chat can exist and still refuse a send.

Guzzle: one client, one handler stack

composer require guzzlehttp/guzzle vlucas/phpdotenv

Configure it once. base_uri, the Bearer header and the timeout become defaults every request inherits; retries live in the handler stack, not in calling code.

<?php
require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

Dotenv\Dotenv::createImmutable(__DIR__)->load();

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
    function (int $tries, RequestInterface $req, ?ResponseInterface $res, ?Throwable $e): bool {
        if ($tries >= 4) {
            return false;
        }
        if ($e instanceof ConnectException) {
            return true;                       // nothing reached us, nothing was billed
        }
        $status = $res?->getStatusCode() ?? 0;
        // Only 429 (refused before metering) and 5xx (debited, then refunded)
        // are free to repeat. A 400/404/413/415/422 is charged and will be
        // charged again on every attempt, so it must not land in here.
        return $status === 429 || $status >= 500;
    },
    function (int $tries, ?ResponseInterface $res): int {
        // Retry-After is seconds; this delay callback is milliseconds.
        $after = (int) ($res?->getHeaderLine('Retry-After') ?: 0);
        return $after > 0 ? $after * 1000 : (int) (500 * 2 ** $tries);
    },
));

$client = new Client([
    'base_uri'    => 'https://api.ofapis.com/api/public/v1/',
    'handler'     => $stack,
    'timeout'     => 15,
    'http_errors' => false,
    'headers'     => [
        'Authorization' => 'Bearer ' . ($_ENV['OFAPIS_KEY'] ?? getenv('OFAPIS_KEY')),
        'Accept'        => 'application/json',
    ],
]);

Two details cost people an afternoon. The base_uri must end in a slash and relative paths must not start with one — Guzzle resolves them per RFC 3986, so $client->get('/me') quietly drops /api/public/v1 and hits the site root. And Middleware::retry re-sends the same PSR-7 request, so a body on a non-seekable stream replays empty; pass json or a string. Throttle on X-RateLimit-Remaining before the middleware ever fires — the per-minute ceiling runs from 60 requests on Free to 500 on Pro.

Decode once, and read the error envelope

http_errors => false above is deliberate: by default a 4xx throws a ClientException whose message truncates the body at 120 characters, and the body is where {"error":{"code","message"}} lives. One helper types both outcomes.

final class OfapisException extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        // Not $code: Exception::$code already exists and is an int, so a
        // promoted readonly string $code is a fatal redeclaration.
        public readonly string $errorCode,
        string $message,
    ) {
        parent::__construct("$status $errorCode: $message", $status);
    }
}

function unwrap(ResponseInterface $res): array
{
    // true => associative arrays. Without it you get stdClass and every
    // ['data']['list'] below turns into ->data->list.
    $json = json_decode((string) $res->getBody(), true, 512, JSON_THROW_ON_ERROR);
    $status = $res->getStatusCode();

    if ($status >= 200 && $status < 300) {
        return $json['data'];
    }
    throw new OfapisException(
        $status,
        $json['error']['code'] ?? 'UNKNOWN',
        $json['error']['message'] ?? '',
    );
}

$me = Me::fromArray(unwrap($client->get('me')));
echo "linked: {$me->username} ({$me->subscribersCount} subs)\n";

$offset = 0;
do {
    $page = unwrap($client->get('chats', ['query' => ['limit' => 50, 'offset' => $offset]]));
    foreach ($page['list'] as $row) {
        $chat = Chat::fromArray($row);
        echo "{$chat->name} — {$chat->unreadMessagesCount} unread\n";
    }
    $offset = $page['nextOffset'] ?? 0;
} while ($page['hasMore'] ?? false);

limit defaults to 20 and the server clamps it to 1–100, so the 50 above is honoured and a hopeful 500 quietly becomes 100 — read count($page['list']) rather than assuming you got what you asked for. The fan-list endpoints are the exception — /accounts/{id}/lists and its /{listId}/users sub-resource both default to 10. Message history does not take offset at all: GET /chats/{chatId}/messages pages backwards on olderThanMessageId, which you set to the id of the oldest message in the page you just read.

The status alone will not tell you what to do next. 402 INSUFFICIENT_CREDITS means top up; ACCOUNT_NOT_LINKED (a charged 422) and OF_SESSION_EXPIRED (a refunded 424) both mean a human must revisit the dashboard. Both are permanent for this attempt, so branch on $e->errorCode, not $e->status (error reference).

What a failed call costs

Neither of those two leaves you out of pocket — the 402 is refused before metering, the 424 is debited and then refunded — but that is a fact about those two codes, not a general rule, and the difference is worth encoding. Credits are debited before the handler runs, so "it failed" and "it was free" are independent questions. Three sets:

final class Billing
{
    /** Refused before metering ever happens. Never debited. */
    public const FREE = [401, 402, 403, 429];

    /** Debited, then refunded when the response is written. */
    public const REFUNDED = [409, 424, 500, 502, 503, 504];

    /** Debited and kept. A validation bug in a loop spends real credits. */
    public const CHARGED = [400, 404, 413, 415, 422];

    /** Weights are not flat; default is 1. */
    public static function costOf(string $method, string $path): int
    {
        if ($method !== 'POST') {
            return 1;
        }
        return match (true) {
            str_ends_with($path, '/media')                                   => 5,
            str_contains($path, '/scheduled/')                               => 3,
            (bool) preg_match('#^/accounts/\d+/chats/\d+/messages$#', $path) => 2,
            default                                                          => 1,
        };
    }
}

So a malformed 422 OF_AUTH_INVALID costs you a credit, and four retries of it cost four. A 413 MEDIA_FILE_TOO_LARGE on a 60 MB upload costs five, because the weight is charged on the way in and the 50 MB ceiling is enforced after that (media upload). Validate size and MIME type (image/jpeg, image/png, image/webp, video/mp4) client-side before you spend the request. Mailings are the one endpoint billed per recipient rather than per call: the trigger itself is weight 0 and the run debits one credit per recipient as it dispatches (how each call is weighted).

Laravel: the Http facade, from a queued job

In Laravel, Illuminate\Http\Client already wraps Guzzle and adds retries and dot-path reads. Keep credentials in config/services.php and dispatch sends from a queue — a third-party round trip has no business holding a PHP-FPM worker open.

// app/Jobs/SendDm.php
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;

class SendDm implements ShouldQueue
{
    use Queueable;

    public int $tries = 5;

    public function __construct(
        private readonly int $chatId,
        private readonly string $text,
        private readonly string $dedupeKey,
        private readonly int $priceCents = 0,
    ) {}

    public function handle(): void
    {
        if (SentDm::where('dedupe_key', $this->dedupeKey)->exists()) {
            return;                       // a previous attempt already delivered
        }

        $res = Http::withToken(config('services.ofapis.key'))
            ->baseUrl('https://api.ofapis.com/api/public/v1')
            ->timeout(15)
            // The server deduplicates on this; a replay returns the original
            // response with Idempotency-Replayed: true and is not charged again.
            ->withHeaders(['Idempotency-Key' => $this->dedupeKey])
            // A ConnectionException has no ->response, so do not type-hint
            // RequestException here: it would TypeError on a dropped socket.
            ->retry(3, function (int $attempt, Throwable $e): int {
                $after = $e instanceof RequestException
                    ? (int) $e->response->header('Retry-After')
                    : 0;
                return $after > 0 ? $after * 1000 : $attempt * 500;
            }, when: function (Throwable $e): bool {
                // Without this, Laravel retries every RequestException — including
                // a 400, which is debited on each attempt and will never succeed.
                if (! $e instanceof RequestException) {
                    return true;        // socket died; nothing was metered
                }
                return in_array($e->response->status(), [429, 500, 502, 503, 504], true);
            }, throw: false)
            ->post("/chats/{$this->chatId}/messages", [
                'text'       => $this->text,
                'price'      => $this->priceCents,   // integer cents
                'lockedText' => $this->priceCents > 0,
            ]);

        if (in_array($res->status(), [402, 424], true)) {
            $this->fail($res->json('error.code'));   // no retry will fix these
            return;
        }
        $res->throw();

        SentDm::create([
            'dedupe_key' => $this->dedupeKey,
            'message_id' => $res->json('data.id'),
        ]);
    }
}

The dedupeKey does two jobs here, and both matter. Locally it is the claim row: a worker that times out mid-flight cannot tell a lost request from a lost response, and Laravel retries either way, so without the check a paying fan gets the same DM twice. On the wire it is the Idempotency-Key, and every POST on the API accepts one. A replay comes back as the original response plus Idempotency-Replayed: true and is not charged a second time; a duplicate that arrives while the first is still in flight gets 409 IDEMPOTENCY_CONFLICT, which is debited and then refunded, so the safe response to a 409 is to wait and read back, not to send again. Do not loop this job over a subscriber list — a broadcast is one call to the mass DM endpoint. Agencies on one key address each creator by id under the mirrored /accounts/{accountId}/... paths (agency automation).

price is an integer of cents

Send 499, never 4.99, and PHP has two ways to get that wrong. (int) (4.99 * 100) is 498 — the product is 498.99999... and a cast truncates rather than rounds, so use (int) round($dollars * 100). And a float that reaches the payload is serialised at serialize_precision, putting 498.99999999999994 on the wire. Keep cents int from the form boundary down and type the property int, so strict mode rejects the mistake at your constructor rather than the API rejecting it later.

FAQ

Why does my ofapis response come back as stdClass instead of an array?

Because json_decode($body) defaults to objects. Pass true as the second argument for associative arrays, and JSON_THROW_ON_ERROR as the fourth — without it a truncated body returns null silently and surfaces as "Trying to access array offset on null" frames away from the real problem.

Can I use Symfony HttpClient or another PSR-18 client instead of Guzzle?

Yes — nothing here needs Guzzle specifically. Type your decode helper against PSR-7's ResponseInterface and the client is swappable for Psr18Client, php-http/curl-client, or Laravel's wrapper. You lose Middleware::retry, so reimplement the Retry-After backoff in whatever that client calls middleware.

Why is Guzzle hiding the API's error message from me?

Its exception message truncates the body to 120 characters, usually cutting off the error.code you need. Set 'http_errors' => false and inspect the status yourself, or catch RequestException and read (string) $e->getResponse()->getBody() — rewind the stream first if something consumed it, and note getResponse() is null on connection failures.

Does Laravel's Http::retry() respect Retry-After on a 429?

Not on its own — it sleeps for the fixed interval you pass. Give the second argument a closure instead: it receives the attempt number and the throwable, so on a RequestException read $e->response->header('Retry-After') and return that many milliseconds. Also pass a when: predicate, because the default retries every RequestException and a 400 is debited on each attempt while never succeeding. Pass throw: false so a final 402 or 424 reaches your own branching.

Where should the API key live in a PHP app?

In the environment, read once at boot: $_ENV['OFAPIS_KEY'] via vlucas/phpdotenv in plain PHP, or config('services.ofapis.key') in Laravel, where config:cache breaks per-request env() lookups. Never in a committed file, and never anywhere the browser can reach — the key authorises spending on the linked account.

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