ofapisv1
solution

Updated

OnlyFans Fan Retention

Every retention playbook assumes a churn event: something fires, a row flips, a webhook arrives. None of that exists here. Nothing notifies you when a subscription lapses, no field records a cancellation date or a reason, and the subscriber record is {id, username, name, avatar, subscribedOn} — five fields, no dates, no spend. The one webhook event type that exists is message.received.

So retention on this API begins with a definition you write down and a history you keep, not a report you pull. What follows is that definition, the cheap signals that feed it, a sequence to run against a segment, and — the part most people skip — how to tell afterwards whether it did anything. Retention also starts earlier than this page does: the first message a new subscriber ever gets is the cheapest lever there is, and that build is welcome message automation.

Churn is a definition you write

There are three defensible definitions and they differ in lag, cost and confidence.

Roster-diff churn. A fan in yesterday's type=active read and absent from today's. One credit per 100 fans, lags by your sync interval, and the only definition that yields a date. Its failure mode is brutal: an incomplete walk looks exactly like a hundred people leaving at once, so never commit a diff unless the walk finished with hasMore false.

Expired-set membership. A fan who appears in GET /subscribers?type=expired. Same cost, no diff required, and it is the authoritative statement of state — but it tells you nothing about when, so you still need yesterday's read to get a date.

Expiry-date churn. The per-fan profile read, GET /users/{targetUserId}, returns subscribedByExpireDate — the one forward-looking field the API offers about a fan: it tells you a subscription is going to end before it does. It costs one credit per fan, so it is a shortlist tool, never a roster tool.

Use the first two together for the population and the third on the few hundred fans worth pre-empting. Detail on the roster reads is on the subscribers API page.

Going quiet is the earlier signal

Lapsing is the end of the story. Silence is the middle, and it is observable for almost nothing if you page the chat index correctly.

GET /chats returns conversations ordered by most recent message, and each row carries a lastMessage with createdAt. So to find everyone who has not spoken in 21 days, page forward until lastMessage.createdAt crosses your cutoff, then stop — you never walk the whole list. On an account with 20,000 chats where 1,100 are newer than the cutoff, that is 11 pages at limit=100: eleven credits, not two hundred. Everything past the cutoff is quiet by construction.

Two more signals come free from the same pass. A row with unreadMessagesCount above zero is the opposite problem — the fan spoke and nobody answered, a service failure rather than a retention one, and worth more attention than any score. And your own outbound log tells you whether the silence is theirs or yours; a fan you last messaged in November is not disengaged, they are neglected. The messaging API has the chat and message shapes.

Tiers, and what each costs to evaluate

Tier Entry rule Source Marginal cost
Warm Inbound message within 7 days /chats, already paged Free
Cooling No inbound 8–20 days, still active /chats + roster Free
Quiet No inbound 21+ days, still active /chats + roster Free
Expiring Quiet and subscribedByExpireDate within 5 days /users/{targetUserId} 1 credit per fan
Lapsed Present in type=expired Roster Free

Note what is missing from the Source column: money. No roster read carries a spend field, so a tier cannot be weighted by what a fan has paid unless you have been accumulating priced-message rows yourself — the shape for that is on revenue analytics.

The ordering is the point: the one expensive signal is evaluated last, on a tier that is already small. The expiry check across a 20,000-fan roster costs 20,000 credits; across the 400 fans who are both quiet and still subscribed it costs 400, which is the difference between a nightly job and a monthly one on any plan.

The win-back sequence

One pass per fan, ever. Each step is conditional on the previous one not having worked.

Day Fires when Action Exit
0 Fan enters Lapsed POST /accounts/{id}/lists/{listId}/users with {"userIds":[…]} to tag them Any inbound message
2 Still lapsed, no inbound Mailing one: the hook, no discount Resubscribe or reply
7 Still lapsed, no inbound Mailing two: the offer Resubscribe or reply
21 Still lapsed DELETE from the list, mark dormant, stop —

Nothing server-side enforces "one pass per fan", so that guard lives in your own send log. Tagging is a bulk call — one POST carries an array of user ids — while removal is one DELETE per fan, so batch the entries and spread the exits. Mechanics are on fan segments; nothing schedules the steps either, so the cron-and-worker shape behind them is the one every other OnlyFans automation job uses.

Triggering a step

AUTH="Authorization: Bearer ofapis_sk_..."
BASE="https://api.ofapis.com/api/public/v1/accounts/42/mailings"

curl -s -X POST "$BASE/$MAILING_ID/preview" -H "$AUTH"
#=> {"data":{"mailingId":"…","listId":"12","recipientCount":218,
#=>          "perRecipientWeight":1,"estimatedCost":218}}

curl -s -X POST "$BASE/$MAILING_ID/trigger" \
     -H "$AUTH" -H "Idempotency-Key: winback-step2-2026-02-14"
#=> {"data":{"runId":"…","recipientCount":218,"creditsDebited":218}}

Two things to guard. The server's duplicate guard rejects a trigger only while a run is PENDING or IN_PROGRESS; re-trigger a mailing that has already COMPLETED and it recounts the list, debits again and sends again — which is why the Idempotency-Key above carries the cycle date. And dispatch is throttled to 500 ms per message, so a 2,000-recipient run needs at least seventeen minutes of wall clock. Treat it as a job, not a request. The mass message page covers the send path.

Measuring it rather than assuming

GET /accounts/{id}/mailings/{mailingId}/runs gives you the denominator: status, recipientCount, successCount, skippedCount, creditsDebited, startedAt, completedAt. Status is PENDING, IN_PROGRESS, PAUSED, COMPLETED or FAILED; PAUSED means the OnlyFans session expired mid-run and dispatch resumes once the account is re-linked, so a paused run is not a failed campaign and must not be re-triggered. Skipped recipients are refunded on completion, so read creditsDebited again afterwards — the trigger-time figure is an upper bound.

The numerator is your next roster walk: fans who were in type=expired on the send date and are in type=active now. That comparison only exists if you stored the earlier read, which is the whole argument for the snapshot table in the CRM build. A subscribed item in the notifications feed catches the same return sooner, as long as you poll inside its head window — it has no offset, so a missed window is a missed resubscribe.

Then the part that makes it a measurement instead of a story: hold back ten per cent of each tier at random and send them nothing. Some fans resubscribe on their own, and without a control group you attribute that baseline to your campaign and keep funding it. Fix an attribution window — fourteen days is reasonable — before you look at the numbers, and count returns inside it only. A fan who comes back on day thirty after two campaigns is not evidence of anything.

Two caveats. The expired set only grows, so a reactivation rate computed against all-time expired falls forever regardless of what you do; scope the denominator to a cohort month. And resubscribes that happen without any message are invisible to attribution — there is no referral or source field, which the CRM API reference lists among the structural gaps.

FAQ

How do I detect a lapse when nothing fires an event?

Compare two consecutive reads of GET /subscribers?type=active and confirm the disappearance against type=expired in the same run. The second read matters: on its own, an absence from the active set is indistinguishable from a page you failed to fetch, and a dropped page in the middle of a walk reads as dozens of simultaneous cancellations.

How far ahead can I see a subscription about to expire?

As far as subscribedByExpireDate on GET /users/{targetUserId} reaches, which is a real future timestamp rather than an estimate. It costs one credit per fan, so query it only for fans already flagged quiet — at that point you are buying a date for a few hundred people instead of a whole roster.

What is a sensible win-back window?

Two touches inside the first ten days after a lapse, then stop. The constraint is not the API, it is that a lapsed fan who has ignored two messages is cheaper to reach through new content than through a third DM, and each extra send costs a credit per recipient — blocked and unsubscribed recipients are refunded when the run completes, but a message that is delivered and ignored is not.

How do I know the win-back caused the resubscribe?

Only by withholding it from a random slice of the same tier and comparing return rates inside a fixed window. Without a holdout you are measuring the organic resubscribe rate and crediting it to the campaign, which is how retention budgets survive for years without working.

Why does my mailing run sit in PAUSED?

Because the OnlyFans session for that account expired part-way through dispatch. The run keeps its place and resumes after the account is re-linked; recipients already marked sent are never re-sent. Re-link the account rather than triggering the mailing again, which would recount the list and charge you a second time.

Related guides

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