OnlyFans API with Rust
The interesting part of the OnlyFans API in Rust is not the HTTP — reqwest handles that in three lines. It is that ofapis wraps every success in {"data": ...} and every failure in {"error": {...}}: exactly the shape a generic ApiResponse<T> and an error enum were made for. Model those two once and the rest is ? operators. ofapis is an independent service, not built or endorsed by OnlyFans; your process only ever holds an ofapis_sk_... key.
Cargo.toml: four crates
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
thiserror = "2"
reqwest's json feature pulls in serde_json and adds .json() to requests and responses — without it those methods do not exist. From tokio, macros gives you #[tokio::main], rt-multi-thread the scheduler, and time is there only so tokio::time::sleep exists for backoff.
Type the envelope once
Two generic structs cover the whole API surface: ApiResponse<T> peels off the wrapper, Page<T> fits every list endpoint. rename_all = "camelCase" bridges hasMore to has_more without a rename on each field.
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct ApiResponse<T> { data: T }
#[derive(Debug, Deserialize)]
struct ApiErrorBody { error: ApiErrorDetail }
#[derive(Debug, Deserialize)]
struct ApiErrorDetail { code: String, message: String }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Page<T> { pub list: Vec<T>, pub has_more: bool, pub next_offset: Option<i64> }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Me {
pub id: i64,
pub username: String,
pub name: String,
pub email: Option<String>,
pub subscribers_count: i64,
pub is_auth: bool,
}
#[derive(Debug, Deserialize)]
pub struct User { pub id: i64, pub name: String, pub username: String, pub avatar: Option<String> }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chat {
pub with_user: User, // the chat is keyed by this id
pub unread_messages_count: i64,
pub can_send_message: bool,
pub last_message: Option<LastMessage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastMessage { pub text: String, pub created_at: String }
Make anything the API may omit an Option<T> — avatar and lastMessage on a fresh chat are the two that bite first.
Errors as a type, not a status code
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("transport: {0}")]
Http(#[from] reqwest::Error),
#[error("bad or revoked key")]
Unauthenticated,
#[error("out of credits")]
InsufficientCredits,
#[error("account unavailable: {code}")]
Account { code: String },
#[error("rate limited, retry in {retry_after}s")]
RateLimited { retry_after: u64 },
#[error("{code}: {message}")]
Api { status: u16, code: String, message: String },
}
#[from] reqwest::Error is what makes ? work on every send().await below. See error codes for the full list.
Two questions get asked of a failure, and they are not the same question. Give each its own method so neither is answered by guesswork at a call site:
impl Error {
/// Did this cost a credit? Debiting happens *before* the handler runs, so
/// a failure is not automatically free. 401/402/403/429 are refused before
/// metering; 409, 424 and 5xx are debited and then refunded; 400, 404,
/// 413, 415 and 422 are debited and stay debited.
pub fn charged(&self) -> bool {
match self {
Error::Http(_) // never reached the server
| Error::Unauthenticated // 401
| Error::InsufficientCredits // 402
| Error::RateLimited { .. } // 429
| Error::Account { .. } => false, // 424 — debited, refunded
// Deref: match ergonomics bind `status` as `&u16` here.
Error::Api { status, .. } => !matches!(*status, 403 | 409 | 500..=599),
}
}
/// Can repeating it succeed? Narrower than `!charged()`: a 424 is free but
/// needs a human to relink the creator, and a 409 means read back instead.
pub fn retryable(&self) -> bool {
matches!(self, Error::RateLimited { .. })
|| matches!(self, Error::Api { status, .. } if *status >= 500)
}
}
One Client, one runtime
reqwest::Client owns a connection pool behind an Arc, so building one per call throws away every keep-alive socket and TLS handshake. Build it once with the Bearer header baked in, then clone it into tasks — a clone is a refcount bump, not a new pool.
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, RETRY_AFTER};
use serde::de::DeserializeOwned;
use std::time::Duration;
const BASE: &str = "https://api.ofapis.com/api/public/v1";
#[derive(Clone)]
pub struct Ofapis { http: reqwest::Client }
impl Ofapis {
pub fn new(token: &str) -> Result<Self, Error> {
let mut auth = HeaderValue::from_str(&format!("Bearer {token}"))
.expect("key is not a valid header value");
auth.set_sensitive(true); // redacted from `{:?}` logs
let mut headers = HeaderMap::new();
headers.insert(AUTHORIZATION, auth);
Ok(Self {
http: reqwest::Client::builder()
.default_headers(headers)
.timeout(Duration::from_secs(15))
.build()?,
})
}
async fn read<T: DeserializeOwned>(res: reqwest::Response) -> Result<T, Error> {
let status = res.status();
if status.is_success() {
return Ok(res.json::<ApiResponse<T>>().await?.data);
}
// headers first — `json()` consumes the response
let retry_after = res
.headers()
.get(RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(1);
let (code, message) = match res.json::<ApiErrorBody>().await {
Ok(b) => (b.error.code, b.error.message),
Err(_) => ("UNKNOWN".into(), format!("HTTP {status}")),
};
Err(match status.as_u16() {
401 => Error::Unauthenticated, // UNAUTHENTICATED
402 => Error::InsufficientCredits, // INSUFFICIENT_CREDITS
424 => Error::Account { code }, // ACCOUNT_NOT_LINKED / OF_SESSION_EXPIRED
429 => Error::RateLimited { retry_after }, // RATE_LIMIT_EXCEEDED
s => Error::Api { status: s, code, message },
})
}
pub async fn me(&self) -> Result<Me, Error> {
Self::read(self.http.get(format!("{BASE}/me")).send().await?).await
}
pub async fn chats(&self, offset: i64) -> Result<Page<Chat>, Error> {
let res = self.http.get(format!("{BASE}/chats"))
.query(&[("limit", 20), ("offset", offset)])
.send().await?;
Self::read(res).await
}
}
Read the key with std::env::var("OFAPIS_KEY"), never a literal — a &'static str key survives into the stripped binary. Grab one from the dashboard (start free); authentication covers rotation.
Paging chats
has_more and next_offset turn into a plain loop with a match that cannot forget the terminal case:
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let api = Ofapis::new(&std::env::var("OFAPIS_KEY")?)?;
let me = api.me().await?;
println!("{} · {} subs · session live: {}", me.username, me.subscribers_count, me.is_auth);
let mut offset = 0i64;
loop {
let page = api.chats(offset).await?;
for chat in &page.list {
println!("{} ({} unread)", chat.with_user.username, chat.unread_messages_count);
}
match (page.has_more, page.next_offset) {
(true, Some(next)) => offset = next,
_ => break,
}
}
Ok(())
}
The 20 in chats() is also the server's default; it clamps whatever you send into 1..=100, so typing i64 there does not buy you a bigger page. Two endpoints break the pattern and are worth a #[doc] comment where you wrap them: the two /lists routes default to 10, and chat history takes no offset at all — it pages backwards on olderThanMessageId, so model that cursor as an Option<i64> rather than reusing Page<T>'s next_offset.
Sending: price is an integer of cents
price is an i64 of cents — 1299 is $12.99. Never type it f64: 12.99_f64 can serialise as 12.990000000000002, and the API will reject or mis-charge it. Omit the field for a free message; #[serde(skip_serializing_if)] does that without a second struct. media_files and previews carry ids of assets already in the vault, never paths or URLs.
use serde::Serialize;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NewMessage<'a> {
pub text: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub price: Option<i64>, // CENTS
pub media_files: Vec<i64>, // vault ids
pub locked_text: bool,
pub previews: Vec<i64>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Sent { pub id: i64, pub text: String, pub price: i64, pub is_free: bool, pub created_at: String }
impl Ofapis {
/// `idem` goes out as Idempotency-Key. Every POST on the API accepts one.
pub async fn send(&self, chat_id: i64, msg: &NewMessage<'_>, idem: &str)
-> Result<Sent, Error>
{
let res = self.http.post(format!("{BASE}/chats/{chat_id}/messages"))
.header("Idempotency-Key", idem)
.json(msg)
.send().await?;
Self::read(res).await
}
}
Because the failure is a typed value, retrying is a match arm rather than a status-code sniff:
let msg = NewMessage {
text: "Thanks for subscribing!",
price: Some(1299),
media_files: vec![],
locked_text: true,
previews: vec![],
};
// One key per logical send, reused by every attempt — not regenerated per try,
// which would defeat the point.
let idem = format!("welcome:{}", chat.with_user.id);
match api.send(chat.with_user.id, &msg, &idem).await {
Ok(sent) => println!("sent #{} for {}c", sent.id, sent.price),
Err(Error::RateLimited { retry_after }) => {
tokio::time::sleep(Duration::from_secs(retry_after)).await;
// Same key: if the first attempt did land, this returns that response
// with Idempotency-Replayed: true and is not charged again.
api.send(chat.with_user.id, &msg, &idem).await?;
}
Err(e) if e.charged() => {
// 400/404/413/415/422 — the credit is gone and a retry spends another.
eprintln!("not retrying a charged failure: {e}");
return Err(e.into());
}
Err(e) => return Err(e.into()),
}
That charged() arm is the whole reason the method exists. The default weight is one credit per call, but it is debited on the way in, so a 422 or a malformed 400 costs the same as a success — only 401, 402, 403 and 429 are refused before metering, and only 409, 424 and 5xx are refunded after it. Weights are not flat: a media upload is 5, a scheduled post or scheduled message is 3, and an account-scoped send is 2, so a 413 on a 60 MB file burns five credits before the size check runs. Ceilings are 60 requests/minute on Free, 100 on Starter and 500 on Pro, reported per response in X-RateLimit-Limit, -Remaining and -Reset — see rate limits and what a call costs.
Several creators under one key
Every route has an account-scoped mirror, so give the struct a second constructor rather than string-formatting at each call site: store an Option<i64> account id and build the prefix as /accounts/{id} or "". That one field is the whole of the multi-tenancy problem in a Rust client; details on scheduling the work are in agency automation.
FAQ
Why does serde fail with "missing field" on a successful response?
You are deserialising into your model instead of through the wrapper. Every 2xx body is {"data": ...}, so the target type is ApiResponse<Me>, not Me. The other half of this error is casing: without #[serde(rename_all = "camelCase")], subscribersCount never reaches subscribers_count.
Do I need serde_json as a direct dependency?
Not for this. reqwest's json feature already depends on it, and derived structs cover every shape here. Add it explicitly only if you want serde_json::Value for fields you deliberately leave unmodelled.
thiserror or anyhow for the API errors?
Both, at different layers. Define the enum with thiserror in the module that wraps the API so callers can match on Error::RateLimited; use anyhow::Result in main, where you only bubble up and print.
How do I share the client across tokio tasks?
Clone it. reqwest::Client and the Ofapis wrapper above are Clone over an internal Arc, so let api = api.clone(); inside each tokio::spawn is the idiom — no Arc<Mutex<_>>, and every task reuses the same pooled connections.
Can I call the API from a non-async binary?
Yes — enable reqwest's blocking feature, or drive the async code with tokio::runtime::Runtime::new()?.block_on(...). Build that runtime once and hold it; creating one per call pays thread-pool setup on every message.