Testing the OnlyFans API Without a Sandbox
There is no sandbox. No test mode, no sandbox. host, no second base URL, no flag on a request. https://api.ofapis.com/api/public/v1 is all there is, and a call from your test runner is indistinguishable from your production worker's. Three things follow:
- Real fans. A
POSTto a messages route puts a DM on a human being's phone. A test that forgets to stub its HTTP client introduces a stranger to your creator. - Real money. Credits are debited in the gateway before your payload is parsed, so a suite is metered whether its assertions go green or red.
- Real rate limit. A parallel runner is a load generator, and the window is a fixed sixty seconds anchored to your first request, so which examples collect the
429shifts between runs.
One half of this API can be faked completely — the inbound webhook, whose signature you compute yourself. The rest is stubbed locally or spent deliberately.
What a careless suite costs, computed
Price a plausible suite against the credit weights:
| What the suite does | Calls | Weight | Credits |
|---|---|---|---|
A /me liveness probe in twelve fixtures |
12 | 1 | 12 |
Walking 400 subscribers at the default limit=20 |
20 | 1 | 20 |
One media upload whose fixture is a .gif → 415 |
1 | 5 | 5 |
Two scheduled-post creates, one dated in the past → 400 |
2 | 3 | 6 |
| One send test retried four times around a bad body | 4 | 2 | 8 |
| One run | 39 | 51 |
Two of those rows are failures and both are billed. The .gif is rejected inside the handler, after the debit, so it burns all five credits of that upload's weight. The malformed send is the expensive shape: a retry wrapper that cannot tell a client mistake from a transient fault pays the weight again on every attempt, so four attempts is four debits. A typo'd path is the same trap with a different number — 404 ACCOUNT_NOT_FOUND, charged and never refunded, per the error reference. At 51 credits a run, the Free tier's 250 a month buy five runs.
Three layers, two of which touch the network
Stubbed unit tests run on every push for nothing. One live contract test runs nightly, or by hand, for a few credits. A burner end-to-end run happens before a release and costs real credits and real DMs. Almost every assertion belongs in the first layer, and its target is the request, not the response — so swap in a transport that records what your client built:
# tests/test_send.py
import httpx
from myapp.ofapis import Client
BASE = "https://api.ofapis.com/api/public/v1"
seen = {}
def recorder(request: httpx.Request) -> httpx.Response:
seen.update(method=request.method, url=str(request.url),
headers=dict(request.headers), body=request.content)
return httpx.Response(200, json={"data": {"id": 9001}})
def test_scoped_send_builds_the_right_request():
c = Client(token="not-a-real-token",
http=httpx.Client(transport=httpx.MockTransport(recorder)))
c.send(account_id=42, chat_id=98765, text="hi", key="welcome-98765")
assert seen["url"] == f"{BASE}/accounts/42/chats/98765/messages"
assert seen["headers"]["idempotency-key"] == "welcome-98765"
assert b'"text":"hi"' in seen["body"]
A stub is worth only its fidelity, and the usual infidelity is giving every error a JSON envelope. Only gateway refusals carry one; a domain error raised in the handler is a bare string with no code field. Model the first shape alone and your fixtures pass a client that crashes on the second:
GATEWAY_429 = httpx.Response(
429,
headers={"Retry-After": "7", "X-RateLimit-Limit": "100", "X-RateLimit-Remaining": "0"},
json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Retry after 7s."}},
)
# No envelope, no code — r.json() raises here, and that is the point of the fixture.
HANDLER_422 = httpx.Response(422, text="The OnlyFans session for account 42 is invalid.")
Layer two is one test, not a suite: a read proving the contract has not drifted, gated so it never fires on a branch push. Layer three needs a creator account you are willing to embarrass — a throwaway profile whose one subscriber is you, on a free-tier token.
@pytest.fixture
def live():
if not os.environ.get("OFAPIS_CONTRACT"):
pytest.skip("live contract test — set OFAPIS_CONTRACT=1 to spend credits")
return httpx.Client(base_url=BASE, headers={"Authorization": f"Bearer {TOKEN}"})
Safe to exercise, and not
| Route | Weight | In an automated test? |
|---|---|---|
GET /me, /accounts/{id}/me |
1 | Yes — cheapest liveness and session probe |
GET /subscribers, /following, /chats |
1 | Yes, but every page is billed |
GET /chats/{chatId}/messages, /vault/media |
1 | Yes — creates nothing |
POST /chats/{chatId}/messages, scoped /accounts/{id}/... |
1, 2 | No — a real DM to a real inbox |
POST /accounts/{id}/media |
5 | Burner only |
POST /accounts/{id}/scheduled/posts or /messages |
3 | Burner, future date, then delete |
POST /accounts/{id}/mailings/{id}/trigger |
0, then per recipient | Never |
The last row turns a bad afternoon into an incident. The duplicate guard rejects a second trigger only while the run is PENDING or IN_PROGRESS; trigger one that already reached COMPLETED and the service recounts the audience, debits again and sends again. A suite that reruns on a green build does exactly that, and a 5,000-fan list is 5,000 credits and 5,000 real DMs, none recallable. Exercise the safe half: preview returns recipientCount and estimatedCost without sending, and is where you learn that targeting all subscribers reports recipientCount as -1 rather than a number you can assert on. The mass DM page has the lifecycle.
Idempotency-Key is a test fixture too
A replay — the same key on the same token, after the first request finished — returns the original status and body with Idempotency-Replayed: true, uncharged. A send test pinned to a constant key therefore bills two credits on its first run and nothing on every rerun that day, still against a real response.
KEY=test-send-98765
for i in 1 2 3; do
curl -sS -D- -o/dev/null -X POST \
"https://api.ofapis.com/api/public/v1/accounts/42/chats/98765/messages" \
-H "Authorization: Bearer $OFAPIS_TOKEN" \
-H "Idempotency-Key: $KEY" -H 'Content-Type: application/json' \
-d '{"text":"contract check"}' | grep -iE 'HTTP/|idempotency-replayed|x-ratelimit-remaining'
done
Two details there matter. The replay is served before the rate limiter runs, so it carries no X-RateLimit-* headers and takes no slot — a rerun is free in both currencies. And the record is scoped to the token and key alone, not the method or path, so a constant like test-1 shared across two cases replays the first one's response at the second.
Reusability follows the refund rule. A key refunded or refused — 409, 424, 5xx, a rate-limit block, an empty balance — is discarded and immediately reusable, so the retry genuinely re-runs; a key that succeeded is not. Between them sits the sharp edge: a charged 400, 404 or 422 is stored as a completed record, so that key replays the error from then on. Handy as a frozen regression fixture, infuriating once you fix the bug — rotate the key or wait out the 24-hour expiry. The production side of all this is API best practices.
Fake the webhook half completely
Inbound deliveries you can reproduce exactly, because the signature is t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> keyed on the endpoint secret's UTF-8 bytes. You hold that secret, so you can mint a valid delivery and post it at your own receiver — no creator, no fan, no credits, since inbound deliveries are not metered.
# tests/webhook.py
import hashlib, hmac, json, time, uuid
def deliver(client, path="/ofapis", *, secret, delivery_id=None, skew=0):
body = json.dumps({
"type": "message.received",
"occurred_at": "2026-11-02T10:00:00Z",
"data": {"chatId": 98765, "message": {
"id": 555001, "text": "hi",
"fromUser": {"id": 98765, "_view": "f"},
"toUser": {"id": 12345, "_view": "t"},
"createdAt": "2026-11-02T10:00:00+00:00"}},
}, separators=(",", ":")).encode()
t = int(time.time()) + skew
v1 = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return client.post(path, content=body, headers={
"Content-Type": "application/json",
"X-Ofapis-Event": "message.received",
"X-Ofapis-Delivery": delivery_id or str(uuid.uuid4()),
"X-Ofapis-Signature": f"t={t},v1={v1}",
})
Sign the exact bytes you post: serialise once, keep the bytes, and never let a framework re-encode the body between signing and sending. Four assertions then cover what otherwise only fails in production. The same delivery_id twice must yield one stored row and one send, since the payload carries no event id of its own; skew=-600 must be rejected if you enforce a tolerance; one flipped character of the secret must give a 401; an unknown type must still answer 2xx. Registering the endpoint is webhook setup — this runs afterwards, in CI, forever.
Make the failure readable
X-RateLimit-Remaining and the echoed X-Request-Id belong in test output: without them, a 429 from a parallel runner and a 422 from a dead session look identical in an assertion diff.
def log(r):
print(f"{r.request.method} {r.request.url.path} -> {r.status_code} "
f"remaining={r.headers.get('X-RateLimit-Remaining')} "
f"request_id={r.headers.get('X-Request-Id')}")
Send your own X-Request-Id and it returns unchanged, so prefix it with the test name and CI run number: a failed nightly then names the exact server-side request to ask about. The same client with retries wired in is in the Python quickstart, one of several on the blog.
FAQ
Is there an OnlyFans API sandbox or test mode?
No. There is no sandbox host, no test flag and no test credential — every call reaches the live account behind your token, spends credits and counts against your per-minute ceiling. The only true no-op is a stub inside your own code.
Will a test suite that hits the API send real DMs?
Yes, if it posts to a messages route. Nothing on the server recognises a test runner, so a send from a unit test lands in a fan's inbox exactly like one from your worker. Block it at the socket and let any unstubbed connection fail the test rather than succeed quietly.
How do I keep a rerun of a send test from charging twice?
Pass a stable Idempotency-Key derived from the test name: the first run is charged, and every replay within 24 hours returns the original response with Idempotency-Replayed: true for nothing. Never share one key between tests — the record is keyed on token and key alone, so a shared key replays the wrong response.
Can I test my webhook handler without a real fan messaging the creator?
Yes, and this is the one piece you can fake end to end. Compute HMAC-SHA256 over "<timestamp>.<raw body>" with the endpoint secret and send it as X-Ofapis-Signature: t=...,v1=... alongside X-Ofapis-Event and X-Ofapis-Delivery. Nothing distinguishes that request from a real delivery, and nothing is metered.
What account should I point end-to-end tests at?
A burner creator account you link yourself, whose single subscriber is a second profile of your own. Never run write tests against a client's live account: a mistimed scheduled post or a stray DM reaches their fans within seconds and cannot be withdrawn.