ofapisv1
integration

Updated

OnlyFans + Google Sheets

Nothing in the Google Workspace Marketplace talks to OnlyFans, and none of the ofapis work needs it. Every call is authenticated by one Authorization: Bearer <token> header — no OAuth consent screen to publish, no signing step — and UrlFetchApp inside Apps Script sends arbitrary headers. A sheet with a time-driven trigger is therefore a complete, scheduled export tool, built in one editor tab. A sheet is the right shape when you want rows to read, diff and chart; when each fan needs notes, an owner and a button that messages them, the Airtable build upserts into records instead.

1. Put the token in Script Properties

  1. In the sheet, open Extensions → Apps Script.
  2. Go to Project Settings → Script Properties → Add script property. Name it OFAPIS_KEY, paste the token as the value, and add OFAPIS_ACCOUNT with the creator id.
  3. Back in the editor, read them with PropertiesService.getScriptProperties().

A token in a cell travels with every copy of the spreadsheet and shows up in every share link; a script property does not. Rotation is covered in authentication.

Then prove the connection with four lines:

function ping() {
  const p = PropertiesService.getScriptProperties();
  const res = UrlFetchApp.fetch('https://api.ofapis.com/api/public/v1/me', {
    headers: { Authorization: 'Bearer ' + p.getProperty('OFAPIS_KEY') },
    muteHttpExceptions: true,
  });
  Logger.log(res.getResponseCode() + ' ' + res.getContentText());
}

muteHttpExceptions: true is the important flag. Without it a 402 or a 429 throws before you can read the body, and the trigger's failure email tells you nothing useful.

2. One page of results, in sheet terms

A success body is {"data": {"list": [...], "hasMore": …, "nextOffset": …}}; an error body is {"error": {"code", "message"}}. A subscriber record contains id, username, name, avatar and subscribedOn — five columns, no spend figure, which is the whole of what a fan record carries. What people usually want next is derived rather than returned, and statistics walks through which read endpoints each metric comes from.

Write the rows with one setValues call, not appendRow in a loop. Each appendRow is a separate round trip to the spreadsheet service and it is what makes a 2,000-fan export time out:

function writeFans(sheet, fans) {
  const rows = fans.map(f => [f.id, f.username, f.name, f.avatar, !!f.subscribedOn]);
  if (rows.length) {
    sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
  }
}

3. Paging, with the throttle built in

Reads take limit (default 20, clamped to 1–100) and offset (default 0). In Apps Script the loop is an ordinary while, which makes Sheets the easiest of these tools to page in — but a single execution is capped at about six minutes, so bound it anyway:

function fetchAllSubscribers() {
  const p = PropertiesService.getScriptProperties();
  const key = p.getProperty('OFAPIS_KEY'), acct = p.getProperty('OFAPIS_ACCOUNT');
  const all = [];
  let offset = 0, hasMore = true, pages = 0;

  while (hasMore && pages++ < 40) {
    const url = `https://api.ofapis.com/api/public/v1/accounts/${acct}` +
                `/subscribers?type=active&limit=100&offset=${offset}`;
    const res = UrlFetchApp.fetch(url, {
      headers: { Authorization: 'Bearer ' + key }, muteHttpExceptions: true,
    });
    const code = res.getResponseCode();

    if (code === 429) {
      Utilities.sleep((Number(res.getHeaders()['Retry-After']) || 30) * 1000);
      continue;
    }
    if (code !== 200) {
      throw new Error(code + ': ' + res.getContentText());
    }

    const body = JSON.parse(res.getContentText()).data;
    all.push.apply(all, body.list);
    hasMore = body.hasMore;
    offset = body.nextOffset != null ? body.nextOffset : offset + 100;
    Utilities.sleep(700);
  }
  return all;
}

Message history is the exception to the offset pattern: /chats/{chatId}/messages pages on the cursor olderThanMessageId, which you set to the id of the oldest message in the page you just read.

4. Quotas on one side, rate limits on the other

Apps Script enforces its own daily UrlFetchApp call quota and its own per-execution time limit, and both depend on your account type — check Google's quota page for the numbers that apply to you before scheduling anything every five minutes.

ofapis enforces a separate per-token, per-minute ceiling: 60 on Free, 100 on Starter, 500 on Pro. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds), readable with res.getHeaders(), and a 429 adds Retry-After in seconds — which is exactly what the Utilities.sleep above reads rather than guessing at a backoff. The Utilities.sleep(700) between pages keeps a 40-page export comfortably under the Free ceiling on its own. See rate limits.

The codes worth branching on are 401 UNAUTHENTICATED, 402 INSUFFICIENT_CREDITS, 403 NO_ACTIVE_SUBSCRIPTION, 409 IDEMPOTENCY_CONFLICT and 429 RATE_LIMIT_EXCEEDED. A malformed query parameter returns 400 with a plain string body rather than the error envelope, so JSON.parse it defensively. Details on error codes.

5. Idempotency on anything that writes

A scheduled export only reads, and reads are safe to repeat. The moment the sheet writes — a row that queues a DM, a button that adds fans to a segment — add an Idempotency-Key header. Repeating the key replays the first response, returns Idempotency-Replayed: true, and is not billed twice:

UrlFetchApp.fetch(`https://api.ofapis.com/api/public/v1/accounts/${acct}/lists/${listId}/users`, {
  method: 'post',
  contentType: 'application/json',
  headers: { Authorization: 'Bearer ' + key, 'Idempotency-Key': `vip-${listId}-${rowDate}` },
  payload: JSON.stringify({ userIds: [98765, 11223] }),
  muteHttpExceptions: true,
});

The key has to be derived from the row, not from the clock, or every re-run makes a new one. Writing to fan lists this way is how a sheet becomes the place segments are maintained.

Worked example: a daily export and a report tab

  1. Two sheets. Fans holds the raw export — id, username, name, avatar, subscribed. Report holds one row per day: date, active, new, lost.
  2. A function that runs both. fetchAllSubscribers() from step 3, then clear Fans with sheet.getRange(2, 1, Math.max(sheet.getLastRow() - 1, 1), 5).clearContent() and rewrite it with writeFans.
  3. Diff before you overwrite. Read the previous id column into a Set first; new is the count of ids that were not there yesterday, lost is the count that disappeared. Append that one row to Report.
  4. A time-driven trigger. In the editor, click the clock icon → Add Trigger → function dailyExport, event source Time-driven, Day timer, 5am–6am. Set Failure notification settings to notify me immediately, so a 402 wakes you rather than silently freezing the report.
  5. Charts off Report, not off Fans. A =SPARKLINE() column and a line chart on the daily counts give you the trend; the raw tab is just the source.

A 2,000-fan roster is 20 successful calls per run — 20 credits a day: a read weighs one credit. Failures are not uniformly free, though — a 429 or a 5xx costs nothing, but a malformed request that comes back 400 or 404 is charged. Scheduling anything heavier than that is worth checking against pricing first, and the whole route list, including the account-scoped paths that let one token export several creators into separate tabs, is in the API documentation. Once the script stops exporting and starts sending, the six-minute execution cap becomes the binding constraint — at that point the Node.js quickstart is the same calls with nothing timing them out.

FAQ

Can I call ofapis from a custom function like =OFSUBS()?

No. Apps Script custom functions cannot use services that require authorisation, and UrlFetchApp is one of them, so a formula calling it fails in the cell. Pull the data with a time-driven trigger or a custom menu item, write it to a tab, and let your formulas read that tab.

Where should the token live?

In Project Settings → Script Properties, read with PropertiesService.getScriptProperties(). A token in a cell is copied with the file, visible to anyone with view access, and exported with the CSV. Script properties stay with the script project.

How long can one pull run before Apps Script stops it?

A single execution is capped at roughly six minutes, so bound the page loop and spread the work rather than assuming a full roster fits in one run. If it does not, store the last offset in a script property and continue on the next trigger.

How do I avoid 429s when exporting thousands of fans?

Request limit=100 so each call does as much work as possible, sleep a few hundred milliseconds between pages, and when a 429 does arrive read the Retry-After header and sleep for exactly that many seconds. Guessed backoffs either waste time or keep you pinned at the ceiling.

What lands in the sheet from one page of results?

Up to 100 subscriber records from data.list, each with id, username, name, avatar and subscribedOn, plus data.hasMore and data.nextOffset telling you whether to ask again. Write them in one setValues call rather than row by row.

Related: Airtable · Zapier · all integrations

Related guides

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