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.
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;
public async Task<Message> SendMessageAsync(long chatId, SendMessage body,
CancellationToken ct = default) =>
(await SendAsync<Envelope<Message>>(
new(HttpMethod.Post, $"chats/{chatId}/messages")
{
Content = JsonContent.Create(body, options: Json)
}, 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 or OF_SESSION_EXPIRED (both 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]),
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. Paging is offset-based: loop while HasMore is true, passing NextOffset back in.
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 several creators 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 Polly retries burn extra credits?
No. Only successful responses are billed, one credit per 2xx, so the 429s and 5xxs a retry policy absorbs cost nothing. The exception is mass messaging, which is billed per recipient rather than per call — see the messaging API.
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.