ofapisv1
quickstart

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.

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(())
}

Sending: price is an integer of cents

price is an i64 of cents1299 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.

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 {
    pub async fn send(&self, chat_id: i64, msg: &NewMessage<'_>) -> Result<Sent, Error> {
        let res = self.http.post(format!("{BASE}/chats/{chat_id}/messages"))
            .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![],
};

match api.send(chat.with_user.id, &msg).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;
        api.send(chat.with_user.id, &msg).await?;
    }
    Err(e) => return Err(e.into()),
}

A 2xx here costs one credit; anything that returns an Err costs nothing, so retries are free. Mailings are billed per recipient instead. 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.

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 "". Details 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.

Related guides

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