OnlyFans API in Go (Golang): generics, context, and errgroup
Reaching the OnlyFans API from Golang is a modelling problem, not a transport one. net/http already speaks the protocol; what you write is the envelope every ofapis response carries, a deadline on every call, and an error type you can branch on. Generics do the first job well enough that one request function covers the whole API. (ofapis is an independent service that talks to OnlyFans on your behalf; it is not built or endorsed by OnlyFans.)
Module and dependencies
The standard library covers auth, JSON and HTTP. The one dependency worth adding is errgroup, for the concurrency section at the end:
go mod init example.com/creatorbot
go get golang.org/x/sync/errgroup
Generate an ofapis_sk_... key in the dashboard (start free), read it with os.Getenv, and keep it out of any binary you ship to a client machine — see authentication.
The envelope, as generic types
Every success is wrapped in {"data": ...}, and every list is a page inside that wrapper. Two generic types cover both shapes for every endpoint you will ever call:
package ofapis
type APIResponse[T any] struct {
Data T `json:"data"`
}
type Page[T any] struct {
List []T `json:"list"`
HasMore bool `json:"hasMore"`
NextOffset int `json:"nextOffset"`
}
type Me struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Email string `json:"email"`
SubscribersCount int `json:"subscribersCount"`
IsAuth bool `json:"isAuth"`
}
type Chat struct {
WithUser struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Avatar string `json:"avatar"`
} `json:"withUser"`
UnreadMessagesCount int `json:"unreadMessagesCount"`
CanSendMessage bool `json:"canSendMessage"`
LastMessage struct {
Text string `json:"text"`
CreatedAt string `json:"createdAt"`
} `json:"lastMessage"`
}
The struct tags matter more than they look: the API uses lowerCamelCase, and encoding/json is case-insensitive on decode but not on encode, so a missing tag silently ships HasMore instead of hasMore the moment you marshal something back.
A chat has no id of its own — it is keyed by withUser.id, which is also the value you put in the message path.
One client, one Transport, a context on every call
Build the *http.Client once and share it. A fresh client per request throws away the connection pool, so every call pays a new TLS handshake:
type Client struct {
base, token string
http *http.Client
}
func New(token string) *Client {
return &Client{
base: "https://api.ofapis.com/api/public/v1",
token: token,
http: &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 20,
IdleConnTimeout: 90 * time.Second,
},
},
}
}
type APIError struct {
Status int `json:"-"`
Code string `json:"code"`
Message string `json:"message"`
}
func (e *APIError) Error() string {
return fmt.Sprintf("ofapis %d %s: %s", e.Status, e.Code, e.Message)
}
// do is a package-level function, not a method: Go methods cannot take type parameters.
func do[T any](ctx context.Context, c *Client, method, path string, body any) (T, error) {
var zero T
var payload io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return zero, err
}
payload = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.base+path, payload)
if err != nil {
return zero, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := c.http.Do(req)
if err != nil {
return zero, err // transport or deadline: nothing reached us, nothing billed
}
defer func() {
io.Copy(io.Discard, res.Body) // drain, or the connection is not reusable
res.Body.Close()
}()
if res.StatusCode >= 400 {
var wrap struct {
Error APIError `json:"error"`
}
json.NewDecoder(res.Body).Decode(&wrap)
wrap.Error.Status = res.StatusCode
return zero, &wrap.Error
}
var out APIResponse[T]
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return zero, err
}
return out.Data, nil
}
Two habits are load-bearing. http.NewRequestWithContext lets a request die with the caller's deadline instead of hanging a goroutine, and the deferred drain-then-close returns the connection to the pool — closing an unread body kills keep-alive and quietly halves throughput.
Errors as values
There are no exceptions to catch. The failure arrives as an error, and errors.As pulls the typed shape back out so you can branch on the code rather than on a bare status number:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
c := New(os.Getenv("OFAPIS_KEY"))
me, err := do[Me](ctx, c, http.MethodGet, "/me", nil)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch apiErr.Code {
case "UNAUTHENTICATED": // 401 — key revoked
case "INSUFFICIENT_CREDITS": // 402 — top up
case "ACCOUNT_NOT_LINKED", "OF_SESSION_EXPIRED": // 424 — relink the creator
case "RATE_LIMIT_EXCEEDED": // 429 — honour Retry-After
}
}
log.Fatal(err)
}
log.Printf("%s, %d subscribers", me.Username, me.SubscribersCount)
Only a 2xx costs a credit, so a returned error is free — retry it without arithmetic. The full table is in the error reference.
Paging, and sending with an integer price
Page[Chat] slots straight into the same function; the compiler resolves APIResponse[Page[Chat]] for you:
offset := 0
for {
page, err := do[Page[Chat]](ctx, c, http.MethodGet,
fmt.Sprintf("/chats?limit=20&offset=%d", offset), nil)
if err != nil {
return err
}
for _, chat := range page.List {
log.Println(chat.WithUser.ID, chat.WithUser.Username, chat.UnreadMessagesCount)
}
if !page.HasMore {
break
}
offset = page.NextOffset
}
price is an int of cents. Declaring it float64 because it represents money is the single most common bug on this endpoint — 9.99 marshals to a decimal the API rejects, and 999 is what $9.99 actually looks like:
type SendMessage struct {
Text string `json:"text"`
MediaFiles []int64 `json:"mediaFiles,omitempty"`
Price int `json:"price,omitempty"` // cents: 999 == $9.99
LockedText bool `json:"lockedText,omitempty"`
Previews []int64 `json:"previews,omitempty"`
}
type Message struct {
ID int64 `json:"id"`
Text string `json:"text"`
CreatedAt string `json:"createdAt"`
IsFree bool `json:"isFree"`
Price int `json:"price"`
}
sent, err := do[Message](ctx, c, http.MethodPost,
fmt.Sprintf("/chats/%d/messages", chat.WithUser.ID),
SendMessage{Text: "New set is live.", Price: 999, LockedText: true})
Fan-out with errgroup, bounded by your rpm ceiling
This is where Go earns its place in an agency stack. Every route has an /accounts/{id}/... twin, so a sweep over fifty creators is fifty independent requests, and errgroup.SetLimit caps in-flight work to keep you under the plan ceiling — 60 rpm on Free, 100 on Starter, 500 on Pro (rate limits):
func unreadEverywhere(ctx context.Context, c *Client, accountIDs []int64) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // eight concurrent requests, comfortably inside the ceiling
for _, id := range accountIDs {
id := id // unnecessary on Go 1.22+, where loop vars are per-iteration
g.Go(func() error {
page, err := do[Page[Chat]](ctx, c, http.MethodGet,
fmt.Sprintf("/accounts/%d/chats?limit=20", id), nil)
if err != nil {
return fmt.Errorf("account %d: %w", id, err)
}
log.Printf("account %d has %d chats", id, len(page.List))
return nil
})
}
return g.Wait()
}
errgroup.WithContext cancels the derived context on the first failure, so one dead account stops the fan-out instead of burning the rest of your minute. Patterns for scheduling that work are in agency automation.
FAQ
Why can't I put a type parameter on my client method in Go?
Go does not allow methods to declare their own type parameters — only the receiver type can be generic. That is why do[T] above is a package-level function taking *Client as an argument. The alternatives are a generic Client (wrong: one client serves many response types) or returning any and asserting at the call site.
Do I really need to drain the response body before closing it?
Yes, if you want connection reuse. net/http only returns a connection to the idle pool once the body has been read to EOF; close it unread and the transport tears it down, so the next call redoes the TLS handshake. io.Copy(io.Discard, res.Body) before Close() is cheap insurance.
Which Go version does this require?
Go 1.18 for the generic envelope; 1.20+ in practice. On 1.22 and later you can delete the id := id line, since loop variables are scoped per iteration.
How many goroutines can I safely run against the API?
Set errgroup.SetLimit from your plan's rpm, not from GOMAXPROCS. A limit of 8 with sub-second responses lands near 400–500 requests a minute — Pro territory; on Free, 1 or 2 is the honest number. Watch X-RateLimit-Remaining and honour Retry-After when a 429 arrives.
Why does my message come back with price 999 instead of 9.99?
price is an integer count of cents in both directions, so 999 is $9.99. Format it for display at the edge of your program; never store it as a float, and never send one.