ofapisv1
quickstart

Updated

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"`
	RetryAfter time.Duration `json:"-"` // parsed from the Retry-After header
}

func (e *APIError) Error() string {
	return fmt.Sprintf("ofapis %d %s: %s", e.Status, e.Code, e.Message)
}

// Retryable reports whether repeating the request can succeed without also
// repeating the charge. See the billing note below.
func (e *APIError) Retryable() bool {
	return e.Status == 429 || e.Status >= 500
}

// opt mutates the outgoing request. Variadic, so existing call sites that pass
// none still compile.
type opt func(*http.Request)

// idempotent is for POSTs: the server replays the stored response instead of
// performing the write twice.
func idempotent(key string) opt {
	return func(r *http.Request) { r.Header.Set("Idempotency-Key", key) }
}

// 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, opts ...opt) (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")
	}
	for _, o := range opts {
		o(req)
	}

	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
		if s, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil {
			wrap.Error.RetryAfter = time.Duration(s) * time.Second
		}
		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": // 422 and 424 — relink the creator
		case "RATE_LIMIT_EXCEEDED":  // 429 — honour Retry-After
		}
	}
	log.Fatal(err)
}
log.Printf("%s, %d subscribers", me.Username, me.SubscribersCount)

A returned error is not automatically a free one, which is why Retryable() above is narrower than "err != nil". Credits are debited before the handler runs, so the status decides what happened to them afterwards:

Status Metered? Safe to repeat?
401, 402, 403, 429 refused before metering 429 yes, the rest no
409, 424, 5xx debited, then refunded 5xx yes; 409 means read back, 424 means a human relinks
400, 404, 413, 415, 422 debited and kept no — same failure, charged again

Weights are not flat either. A call is 1 credit by default, a file sent to the vault via POST /accounts/{id}/media is 5, POST /accounts/{id}/scheduled/posts and .../scheduled/messages are 3 each, and an account-scoped POST /accounts/{id}/chats/{chatId}/messages is 2. A mailing trigger is weight 0 at the call and the run debits one credit per recipient. The full status table is in the error reference; the arithmetic is in API cost.

A retry loop that honours Retry-After

Generics make the wrapper trivial, and Retryable() keeps a charged 400 out of the loop. A 429 carries Retry-After in seconds; everything else gets 1s, 2s, 4s:

func withRetry[T any](ctx context.Context, c *Client, method, path string,
	body any, attempts int, opts ...opt) (T, error) {

	var zero T
	for i := 0; ; i++ {
		out, err := do[T](ctx, c, method, path, body, opts...)
		if err == nil {
			return out, nil
		}
		var apiErr *APIError
		if !errors.As(err, &apiErr) || !apiErr.Retryable() || i >= attempts-1 {
			return zero, err
		}
		wait := apiErr.RetryAfter
		if wait <= 0 {
			wait = time.Duration(1<<i) * time.Second
		}
		select {
		case <-ctx.Done():
			return zero, ctx.Err() // the caller's deadline wins over Retry-After
		case <-time.After(wait):
		}
	}
}

time.Sleep would be the wrong primitive: it ignores cancellation, so a 30-second Retry-After would outlive a context that was cancelled a moment after the 429 arrived.

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
}

Hard-code nothing about page size: limit defaults to 20 and the server clamps it into 1–100, so a struct field of 500 comes back as 100 rows and a loop that increments offset by its own constant starts skipping. Feed page.NextOffset back, as above. (/accounts/{id}/lists and its /users sub-resource default to 10 instead, and chat history pages on an olderThanMessageId cursor rather than offset — see the lists API and the chat API.)

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 := withRetry[Message](ctx, c, http.MethodPost,
	fmt.Sprintf("/chats/%d/messages", chat.WithUser.ID),
	SendMessage{Text: "New set is live.", Price: 999, LockedText: true},
	3,
	// Derive the key from what the send *is*, not from the attempt: same drop,
	// same fan, same key on every retry and across process restarts. dropID is
	// your own campaign identifier, not anything the API hands you.
	idempotent(fmt.Sprintf("%s:%d", dropID, chat.WithUser.ID)))

Never send a POST through a retry loop without that header. The retry cannot tell a lost request from a lost response, and a resend of a 1299-cent locked PPV message charges the fan twice. With the key, the second attempt returns the first attempt's body and Idempotency-Replayed: true at no extra credit cost; if the two attempts overlap, the loser gets 409 IDEMPOTENCY_CONFLICT, which is debited then refunded.

Fan-out with errgroup, bounded by your rpm ceiling

This is where Go earns its place in an agency's 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.

Same quickstart, another language

Related guides

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