OnlyFans API with C#
The shortest correct path to an OnlyFans API C# integration is a typed client registered with IHttpClientFactory, record DTOs bound by System.Text.Json, and a resilience handler that respects Retry-After. Everything below assumes .NET 8 or later. ofapis is an independent service that speaks to OnlyFans on your behalf, neither built nor endorsed by OnlyFans; sessions stay alive server-side, so your process only ever holds an ofapis_sk_... key.
dotnet new console -n Ofapis.Quickstart
cd Ofapis.Quickstart
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Http.Resilience
Register a typed client, not a new HttpClient()
This is the one decision that bites .NET developers specifically. new HttpClient() per request leaves a TIME_WAIT socket behind for every call — a few hundred messages a minute and you are out of ephemeral ports. The obvious fix, a static readonly HttpClient, swaps that for the opposite bug: the handler caches the resolved IP forever and never notices a DNS rotation. IHttpClientFactory solves both by recycling the underlying handler on a rolling lifetime, and the typed-client overload lets you set the base address and the Bearer header exactly once:
// Program.cs
using System.Net.Http.Headers;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<OfapisClient>(client =>
{
client.BaseAddress = new Uri("https://api.ofapis.com/api/public/v1/");
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["Ofapis:Key"]!);
client.Timeout = TimeSpan.FromSeconds(30);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 4;
options.Retry.ShouldRetryAfterHeader = true; // 429 → wait exactly what the server said
});
AddStandardResilienceHandler is the .NET 8 successor to hand-rolled Polly pipelines: rate limiter, timeout, retry and circuit breaker in one call. With ShouldRetryAfterHeader on, a 429 RATE_LIMIT_EXCEEDED is slept off for precisely the Retry-After interval instead of a guessed backoff. Ceilings are 60 requests per minute on Free, 100 on Starter and 500 on Pro, and every response carries X-RateLimit-Limit, -Remaining and -Reset if you would rather pace yourself — see rate limits.
Keep the URI trailing slash and the relative paths slash-free. new Uri(baseAddress, "me") drops the last path segment otherwise, and you will silently call /api/public/me.
What the standard handler does not do is exempt writes. Its retry predicate is IsTransient — 5xx, 408 and 429 — so it will never replay a 400, which is the important half. But it does not look at the HTTP method, so a POST that times out mid-flight is re-sent, and the handler cannot know whether the first attempt landed. Attach an Idempotency-Key to every write and the question stops mattering.
Model the envelope with records
Every success is wrapped as {"data": ...} and every failure as {"error":{"code","message"}}. One generic record covers the first case, so no endpoint needs a bespoke wrapper:
// Dtos.cs
using System.Text.Json.Serialization;
public sealed record Envelope<T>(T Data);
public sealed record ApiError(string Code, string Message);
public sealed record ErrorEnvelope(ApiError Error);
public sealed record Me(long Id, string Username, string Name, string Email,
int SubscribersCount, bool IsAuth);
public sealed record Page<T>(IReadOnlyList<T> List, bool HasMore, int? NextOffset);
public sealed record ChatUser(long Id, string Name, string Username, string? Avatar);
public sealed record LastMessage(string Text, DateTimeOffset CreatedAt);
public sealed record Chat(ChatUser WithUser, int UnreadMessagesCount,
bool CanSendMessage, LastMessage? LastMessage);
public sealed record SendMessage(
string Text,
long[]? MediaFiles = null,
int Price = 0, // CENTS. See below.
bool LockedText = false,
long[]? Previews = null);
public sealed record Message(long Id, string Text, DateTimeOffset CreatedAt,
bool IsFree, int Price);
Positional records bind because System.Text.Json matches constructor parameters by name once PropertyNamingPolicy is camel case. Nothing needs [JsonPropertyName].
The client itself
CancellationToken threads through every method, so an ASP.NET Core request abort or a CancellationTokenSource timeout unwinds a send cleanly instead of leaving a half-open socket.
// OfapisClient.cs
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class OfapisClient(HttpClient http)
{
private static readonly JsonSerializerOptions Json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
public async Task<Me> GetMeAsync(CancellationToken ct = default) =>
(await SendAsync<Envelope<Me>>(new(HttpMethod.Get, "me"), ct)).Data;
public async Task<Page<Chat>> GetChatsAsync(int limit = 20, int offset = 0,
CancellationToken ct = default) =>
(await SendAsync<Envelope<Page<Chat>>>(
new(HttpMethod.Get, $"chats?limit={limit}&offset={offset}"), ct)).Data;
// idempotencyKey is required, not optional: a retried send without one can
// charge the fan twice. Derive it from the send, never from the attempt.
public async Task<Message> SendMessageAsync(long chatId, SendMessage body,
string idempotencyKey,
CancellationToken ct = default) =>
(await SendAsync<Envelope<Message>>(
new(HttpMethod.Post, $"chats/{chatId}/messages")
{
Content = JsonContent.Create(body, options: Json),
Headers = { { "Idempotency-Key", idempotencyKey } }
}, ct)).Data;
private async Task<T> SendAsync<T>(HttpRequestMessage request, CancellationToken ct)
{
using var response = await http.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var problem = await response.Content
.ReadFromJsonAsync<ErrorEnvelope>(Json, ct);
throw new OfapisException((int)response.StatusCode,
problem?.Error.Code ?? "UNKNOWN", problem?.Error.Message ?? "");
}
return (await response.Content.ReadFromJsonAsync<T>(Json, ct))!;
}
}
public sealed class OfapisException(int status, string code, string message)
: Exception($"{status} {code}: {message}")
{
public int Status { get; } = status;
public string Code { get; } = code;
}
Switch on OfapisException.Code, not the status number: UNAUTHENTICATED means a bad key, INSUFFICIENT_CREDITS means top up, and ACCOUNT_NOT_LINKED (a charged 422) or OF_SESSION_EXPIRED (a refunded 424) mean a human must reconnect the creator in the dashboard — no retry policy fixes those. Full list in the error reference.
Read, page, send
var host = builder.Build();
var ofapis = host.Services.GetRequiredService<OfapisClient>();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var me = await ofapis.GetMeAsync(cts.Token);
Console.WriteLine($"{me.Username}: {me.SubscribersCount} subscribers, live={me.IsAuth}");
var page = await ofapis.GetChatsAsync(limit: 50, ct: cts.Token);
foreach (var chat in page.List.Where(c => c.CanSendMessage && c.UnreadMessagesCount > 0))
{
var sent = await ofapis.SendMessageAsync(chat.WithUser.Id,
new SendMessage("New set is live — unlock below.",
MediaFiles: [90210L, 90211L],
Price: 1299, // $12.99
LockedText: true,
Previews: [90210L]),
idempotencyKey: $"drop-2026-03:{chat.WithUser.Id}",
cts.Token);
Console.WriteLine($"→ {chat.WithUser.Username}: message {sent.Id} at {sent.CreatedAt:O}");
}
// keep going while page.HasMore, feeding page.NextOffset back into GetChatsAsync
A chat is addressed by withUser.Id, not by a separate conversation id, and MediaFiles and Previews take ids of assets already sitting in the vault rather than file paths. Paging is offset-based: loop while HasMore is true, passing NextOffset back in. The limit: 50 above is accepted; the parameter defaults to 20 and the server clamps it into 1–100, so a limit property typed int with a value of 1000 does not get you a thousand rows, it gets you a hundred and a loop that thinks it is finished. /accounts/{id}/lists and /accounts/{id}/lists/{listId}/users default to 10 rather than 20, and the message-history endpoint has no offset at all — it pages backwards from olderThanMessageId, so give it its own method signature rather than overloading GetChatsAsync.
If the same key is replayed, the response is the original one plus an Idempotency-Replayed: true header and no second charge; if two attempts overlap, the loser gets 409 IDEMPOTENCY_CONFLICT, which is debited and then refunded.
Price is an int of cents. The C# instinct is decimal, because that is the right money type in domain code — and it is, in your domain code. On the wire, 12.99m serialises as 12.99 and the API reads twelve cents. Convert at the boundary with (int)(amount * 100m) and keep the DTO integral.
Native AOT and source-generated JSON
Reflection-based System.Text.Json gets trimmed away under PublishAot or PublishTrimmed. Declare a context and the serializers are generated at compile time — faster, and warning-free for AOT:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Envelope<Me>))]
[JsonSerializable(typeof(Envelope<Page<Chat>>))]
[JsonSerializable(typeof(Envelope<Message>))]
[JsonSerializable(typeof(ErrorEnvelope))]
[JsonSerializable(typeof(SendMessage))]
internal sealed partial class OfapisJsonContext : JsonSerializerContext;
Then swap the options argument for the generated metadata — ReadFromJsonAsync(OfapisJsonContext.Default.EnvelopeMe, ct). Generic arguments are flattened into the property name, so Envelope<Page<Chat>> becomes EnvelopePageChat.
Running a whole agency roster from one process? Prefix the path with accounts/{accountId}/ — same typed client, same token, one route segment more. See agency automation for the fan-out patterns.
FAQ
Why register a typed client instead of a static readonly HttpClient?
A static client never refreshes DNS, so after a failover it keeps dialling an address that no longer answers. AddHttpClient<OfapisClient> rotates the handler on a two-minute default lifetime while still pooling connections, which gets you connection reuse without the stale-DNS failure mode.
How do I deserialize the data envelope without a wrapper class per endpoint?
Declare one record Envelope<T>(T Data) and deserialize into Envelope<Me>, Envelope<Page<Chat>> and so on. With PropertyNamingPolicy = JsonNamingPolicy.CamelCase, positional record parameters bind straight to the JSON with no attributes.
Do the resilience handler's retries burn extra credits?
Not the ones it actually performs. Credits are debited before the handler runs, but a 429 is refused before metering and a 5xx is debited and then refunded, and those are the statuses IsTransient retries. What does cost you is anything outside that set: 400, 404, 413, 415 and 422 are charged and not refunded, so a hand-rolled policy that widens the predicate to "any non-2xx" pays for the same failure once per attempt. Mass messaging is the separate case, billed per recipient rather than per call — see the messaging API.
Is a credit always one credit?
No, the weight depends on the endpoint. The default is 1, but POST /accounts/{id}/media is 5, POST /accounts/{id}/scheduled/posts and POST /accounts/{id}/scheduled/messages are 3 each, and the account-scoped POST /accounts/{id}/chats/{chatId}/messages is 2. Because the debit happens on the way in, a 413 MEDIA_FILE_TOO_LARGE on an oversized upload costs the full 5 — check the 50 MB limit and the allowed types (image/jpeg, image/png, image/webp, video/mp4) before you call. API cost has the breakdown.
Where should the API key live in an ASP.NET Core app?
In configuration, not source. Use dotnet user-secrets set "Ofapis:Key" ofapis_sk_... locally and an environment variable or Key Vault in production, then read builder.Configuration["Ofapis:Key"] inside the AddHttpClient callback. Create a key in the dashboard.
Does this work under Native AOT?
Yes, with a source-generated JsonSerializerContext. HttpClient, IHttpClientFactory and Microsoft.Extensions.Http.Resilience are all AOT-compatible; only reflection-based serialization is not.