# OnlyFans PHP SDK

> A typed Composer package for the OnlyFans API. Call any endpoint from plain PHP or Laravel, with typed responses and automatic retries.

- Requires PHP 8.1+ and a PSR-18 HTTP client (for example Guzzle)
- Source: https://github.com/onlyfansapi/onlyfansapi-php
- Package: `onlyfansapi/onlyfansapi-php` on Packagist: https://packagist.org/packages/onlyfansapi/onlyfansapi-php (Apache 2.0)

## Make your first OnlyFans call in PHP

Install the package with Composer. The SDK does not bundle an HTTP client; it uses any PSR-18 client in your project, so add Guzzle if you have none:

```bash
composer require onlyfansapi/onlyfansapi-php guzzlehttp/guzzle
```

Store your API key in the `ONLYFANSAPI_API_KEY` environment variable: export it in your shell, or keep it in `.env` (Laravel loads it for you; in plain PHP, use `vlucas/phpdotenv`). `new Client()` reads the variable automatically, or pass it with `new Client(apiKey: ...)`.

```bash
export ONLYFANSAPI_API_KEY=your_api_key

# Laravel reads .env for you. In plain PHP, keep the key in .env with
# vlucas/phpdotenv: Dotenv\Dotenv::createImmutable(__DIR__)->load();
```

List the OnlyFans accounts connected to your key. Pass the `acct_` ID to every other call:

```php
<?php

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

use Onlyfansapi\Client;

// Reads ONLYFANSAPI_API_KEY from the environment
$client = new Client();

// GET /api/accounts: every OnlyFans account connected to your API key
$accounts = $client->accounts->list();

foreach ($accounts as $account) {
  echo "{$account->id} · @{$account->onlyfansUsername}", PHP_EOL;
}
// Output:
// acct_XXXXXXXXXXXXX · @sarah_k
```

## OnlyFans PHP SDK code examples

Send a paid message: upload a photo, then attach the returned `ofapi_media_` ID (usable once). Prices are in USD: 0, or 3 to 200.

```php
<?php

use Onlyfansapi\Client;
use Onlyfansapi\Core\FileParam;

$client = new Client();
$account = 'acct_XXXXXXXXXXXXX';

// POST /api/{account}/media/upload
// The returned ID can be attached to one post or message.
$fd = fopen(__DIR__ . '/preview.jpg', 'r');
try {
  $upload = $client->media->upload(
    $account,
    file: FileParam::fromResource($fd, filename: 'preview.jpg', contentType: 'image/jpeg'),
  );
} finally {
  fclose($fd);
}

// POST /api/{account}/chats/{chat_id}/messages
$message = $client->chats->messages->send(
  '987654321',
  account: $account,
  text: 'Something new, just for you 💜',
  mediaFiles: [$upload->prefixedID],
  price: 10, // USD, 0 or 3–200
);

echo "PPV sent #{$message->data->id}", PHP_EOL;
```

Other examples on the page: link stats (clicks, subscribers, spenders, lifetime revenue for a tracking link), link spenders, listing chats, reading chat messages, sending a message, and downloading media from an OnlyFans CDN URL through the API's proxy and cache.

## Features

- **Modern PHP** — Built for PHP 8.1+: named arguments for optional parameters, enums for fixed values, and typed objects for every response.
- **Configurable retries** — Two automatic retries with backoff on 429 and 5xx responses. Change the count for the whole client or for one call.
- **Focused exceptions** — One exception class per failure: authentication, not found, rate limit, server error and connection problems.

## Use the OnlyFans PHP SDK in Laravel

Keep the key and the webhook signing secret in config, so `php artisan config:cache` keeps working:

```php
<?php

// config/services.php
return [
    // ...

    'onlyfansapi' => [
        'key' => env('ONLYFANSAPI_API_KEY'),
        'webhook_secret' => env('ONLYFANSAPI_WEBHOOK_SECRET'),
    ],
];
```

Register the client as a singleton, then type-hint `Onlyfansapi\Client` in controllers, jobs and commands:

```php
<?php

// app/Providers/AppServiceProvider.php
use Onlyfansapi\Client;

public function register(): void
{
    $this->app->singleton(Client::class, fn () => new Client(
        apiKey: config('services.onlyfansapi.key'),
    ));
}
```

```php
<?php

// routes/web.php
use Onlyfansapi\Client;

Route::get('/fans/latest', function (Client $onlyfans) {
    // GET /api/{account}/fans/latest
    $fans = $onlyfans->fans->listLatest('acct_XXXXXXXXXXXXX', limit: 10);

    return collect($fans->data->users)->pluck('username');
});
// Output:
// ["sarah_k","mike_r"]
```

Receive webhooks on a signed route. OnlyFansAPI sends an HMAC SHA256 of the raw body in the `Signature` header and a stable `X-OFAPI-Idempotency-Key` per event. Queue the work and answer with a 2xx within 10 seconds. Use a shared cache store (Redis or database) for the duplicate check.

```php
<?php

// routes/web.php
// Also exclude the path from CSRF in bootstrap/app.php:
// $middleware->validateCsrfTokens(except: ['webhooks/onlyfans']);
use App\Jobs\ProcessOnlyFansWebhook;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

Route::post('/webhooks/onlyfans', function (Request $request) {
    $expected = hash_hmac('sha256', $request->getContent(), config('services.onlyfansapi.webhook_secret'));

    abort_unless(hash_equals($expected, (string) $request->header('Signature')), 401);

    // Retries reuse the same key, so each event is queued once.
    $key = $request->header('X-OFAPI-Idempotency-Key');
    if ($key && ! Cache::add("ofapi-webhook:{$key}", true, now()->addDay())) {
        return response()->noContent();
    }

    try {
        ProcessOnlyFansWebhook::dispatch($request->json()->all());
    } catch (Throwable $e) {
        // Queueing failed: forget the key so the retry is not skipped.
        if ($key) {
            Cache::forget("ofapi-webhook:{$key}");
        }

        throw $e;
    }

    return response()->noContent();
});
```

```php
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;

class ProcessOnlyFansWebhook implements ShouldQueue
{
    use Queueable;

    public function __construct(public array $event) {}

    public function handle(): void
    {
        match ($this->event['event']) {
            'subscriptions.new' => Log::info('New subscriber', $this->event['payload']['fan']),
            'messages.received' => Log::info('New message on '.$this->event['account_id']),
            default => null,
        };
    }
}
```

## Load every fan, chat or post page by page

A page can hold fewer items than `limit` and still not be the last one, because OnlyFans filters some entries on its side. Trust `hasMore` and move the offset by the full limit:

```php
<?php

use Onlyfansapi\Client;

$client = new Client();

$account = 'acct_XXXXXXXXXXXXX';
$limit = 20;
$offset = 0;
$usernames = [];

do {
  // GET /api/{account}/fans/all
  $page = $client->fans->listAll($account, limit: $limit, offset: $offset);

  foreach ($page->data->list as $fan) {
    $usernames[] = $fan->username;
  }

  // A page can hold fewer than $limit fans and still not be the last one.
  // Trust hasMore, and move the offset by the full limit.
  $offset += $limit;
} while ($page->data->hasMore);

echo count($usernames) . ' fans loaded', PHP_EOL;
```

Export every active fan to CSV:

```php
<?php

use Onlyfansapi\Client;

$client = new Client();

$csv = fopen(__DIR__ . '/fans.csv', 'w');
fputcsv($csv, ['id', 'username', 'name'], escape: '');

$offset = 0;
do {
  // GET /api/{account}/fans/active
  $page = $client->fans->listActive('acct_XXXXXXXXXXXXX', limit: 20, offset: $offset);

  foreach ($page->data->list as $fan) {
    fputcsv($csv, [$fan->id, $fan->username, $fan->name], escape: '');
  }

  $offset += 20;
} while ($page->data->hasMore);

fclose($csv);
```

## Handle errors, retries and timeouts

Each HTTP status maps to its own exception class, all extending `APIException`:

```php
<?php

use Onlyfansapi\Client;
use Onlyfansapi\Core\Exceptions\APIConnectionException;
use Onlyfansapi\Core\Exceptions\APIStatusException;
use Onlyfansapi\Core\Exceptions\AuthenticationException;
use Onlyfansapi\Core\Exceptions\NotFoundException;
use Onlyfansapi\Core\Exceptions\RateLimitException;

$client = new Client();

try {
  $chats = $client->chats->list('acct_XXXXXXXXXXXXX', limit: '10');
} catch (AuthenticationException $e) {
  // 401: the API key is missing, revoked or mistyped
} catch (NotFoundException $e) {
  // 404: wrong account ID, or the account was disconnected
} catch (RateLimitException $e) {
  // 429: still limited after the SDK's own retries, so back off
} catch (APIConnectionException $e) {
  // Network failure or HTTP client timeout
} catch (APIStatusException $e) {
  // Any other 4xx or 5xx response
  error_log($e->getMessage());
}
```

| Cause | Exception |
| --- | --- |
| HTTP 400 | `BadRequestException` |
| HTTP 401 | `AuthenticationException` |
| HTTP 403 | `PermissionDeniedException` |
| HTTP 404 | `NotFoundException` |
| HTTP 409 | `ConflictException` |
| HTTP 422 | `UnprocessableEntityException` |
| HTTP 429 | `RateLimitException` |
| HTTP >= 500 | `InternalServerException` |
| Other HTTP error | `APIStatusException` |
| Network error or timeout | `APIConnectionException` |

Responses with status 408, 409, 429 and 5xx are retried twice with exponential backoff, following `Retry-After`. Connection errors are thrown straight away.

```php
<?php

use Onlyfansapi\Client;

// 408, 409, 429 and 5xx responses are retried twice by default,
// with exponential backoff that respects the Retry-After header.
$client = new Client(requestOptions: [
  'maxRetries' => 4,
  'initialRetryDelay' => 1.0, // seconds
  'maxRetryDelay' => 10.0,
]);

// Or switch retries off for one call, e.g. inside a web request
$chats = $client->chats->list(
  'acct_XXXXXXXXXXXXX',
  limit: '10',
  requestOptions: ['maxRetries' => 0],
);
```

Timeouts are set on the HTTP client:

```php
<?php

use GuzzleHttp\Client as Guzzle;
use Onlyfansapi\Client;

// Timeouts belong to the HTTP client, so pass your own Guzzle instance
$client = new Client(requestOptions: [
  'transporter' => new Guzzle([
    'connect_timeout' => 5,
    'timeout' => 30,
  ]),
]);

// A request that runs past 30 seconds throws APIConnectionException
```

Call endpoints the SDK has no method for yet, with the same authentication and retries:

```php
<?php

use Onlyfansapi\Client;

$client = new Client();

// Call an endpoint the SDK has no method for yet.
// Authentication and retries still apply.
$response = $client->request(
  method: 'get',
  path: 'api/acct_XXXXXXXXXXXXX/some-new-endpoint',
  query: ['limit' => 10],
);

// Or add a parameter the SDK does not know about to a typed call
$chats = $client->chats->list(
  'acct_XXXXXXXXXXXXX',
  requestOptions: ['extraQueryParams' => ['new_filter' => 'value']],
);
```

## Track credits and rate limits from PHP

Every response carries `_meta` with credits, rate limits and cache state. Cached responses cost 0 credits but still count toward your rate limit.

```php
<?php

use Onlyfansapi\Client;

$client = new Client();

$fans = $client->fans->listAll('acct_XXXXXXXXXXXXX', limit: 20);

// Every response carries _meta with credits, rate limits and cache state
$meta = $fans->_meta;

echo "Credits used: {$meta->_credits->used}", PHP_EOL;
echo "Credits left: {$meta->_credits->balance}", PHP_EOL;
echo "Requests left this minute: {$meta->_rateLimits->remainingMinute}", PHP_EOL;
echo 'From cache: ' . ($meta->_cache->isCached ? 'yes' : 'no'), PHP_EOL;
// Output:
// Credits used: 1
// Credits left: 9876
// Requests left this minute: 998
// From cache: no
```

## What PHP teams build with the OnlyFans API

Analytics dashboards in a Laravel admin panel, media workflows for posts and paid messages, scheduled automation with cron or Laravel's scheduler, multi-account tools, reporting pipelines into MySQL, PostgreSQL or spreadsheets, and integrations with WordPress or a CRM.

## FAQ

### How do I install the OnlyFans PHP SDK?

Run `composer require onlyfansapi/onlyfansapi-php`. The package needs PHP 8.1 or newer and a PSR-18 HTTP client. If your project has none, add `guzzlehttp/guzzle` to the same command.

### Does the OnlyFans PHP SDK work with Laravel?

Yes. Bind `Onlyfansapi\Client` as a singleton in `AppServiceProvider`, keep the key in `config/services.php`, and inject the client into controllers, jobs and commands. See the Laravel section above, including a signed webhook route.

### Which PHP versions are supported?

PHP 8.1 and newer, because the SDK relies on named arguments and enums. It runs on PHP 8.4 as well.

### How do I authenticate?

Set the `ONLYFANSAPI_API_KEY` environment variable, or pass `apiKey` when you create the client. Reading the key from the environment keeps it out of source control.

### How do I verify webhook signatures in PHP?

Compute `hash_hmac('sha256', $rawBody, $signingSecret)` and compare it with the `Signature` header using `hash_equals`. Hash the raw request body, not JSON you decoded and encoded again, or the signature will not match.

### What happens when a request fails?

The SDK throws a subclass of `APIException`, such as `AuthenticationException`, `NotFoundException` or `RateLimitException`. Responses with status 408, 409, 429 or 5xx are first retried twice with exponential backoff. Connection errors are thrown straight away.

### Can I upload files?

Yes. Pass a resource from `fopen()`, a string of file contents, or a `FileParam` with a filename and content type. The upload returns an `ofapi_media_` ID that you attach to one post or message.

### Can I call an endpoint that is not in the SDK yet?

Yes. `$client->request()` calls any path with the same authentication and retries, and the `extraQueryParams`, `extraBodyParams` and `extraHeaders` request options add fields the SDK does not know about yet.

### Is the OnlyFans PHP SDK free?

Yes. The SDK is open source under the Apache 2.0 license and free to install. The API calls it makes use credits from your OnlyFansAPI plan.

## Links

- Get your API key: https://app.onlyfansapi.com/register
- Source on GitHub: https://github.com/onlyfansapi/onlyfansapi-php
- API reference: https://docs.onlyfansapi.com/api-reference/overview
- Webhook signatures: https://docs.onlyfansapi.com/webhooks/protecting-your-webhooks
- All SDKs: https://onlyfansapi.com/sdk