ofapisv1
quickstart

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;
        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 — ceilings run 60 requests per minute 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);

The status alone will not tell you what to do next. 402 INSUFFICIENT_CREDITS means top up; the two 424s, ACCOUNT_NOT_LINKED and OF_SESSION_EXPIRED, mean a human must revisit the dashboard. Both are permanent for this attempt, so branch on $e->errorCode, not $e->status (error reference). Neither is billed: metering counts successful calls only, one credit per 2xx, mailings excepted since those are charged per recipient.

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)
            // 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;
            }, 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 row is the point. A worker that times out mid-flight cannot tell a lost request from a lost response, and Laravel retries either way; without the claim check, a paying fan gets the same DM twice. 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. 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.

Related guides

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