ofapisv1
integration

Updated

OnlyFans + Airtable

Airtable has no OnlyFans plugin and ofapis has not built one. Nothing is blocked by that: ofapis takes one Authorization: Bearer <token> header and nothing else — no OAuth handshake, no signed request — and Airtable's Run script automation action gives you a fetch that sends exactly that. The result is a fan CRM that updates itself on a schedule, with no server of your own in between. If what you actually want is a flat list to read and chart, the same export into Google Sheets is less setup; choose Airtable when the rows need linked records, button fields and per-record automations hanging off them.

1. Build the table before the script

Make a Fans table whose fields match what the API returns:

Field Type Source
Fan ID Single line text id, stringified — this is the match key
Username Single line text username
Name Single line text name
Avatar URL avatar
Subscribed Checkbox subscribedOn
Last Synced Date (with time) set by the script

That is the whole subscriber record. There is no lifetime-spend or last-active field on it, so plan the base around what exists — subscribers lists the shape, and statistics explains how the numbers people expect are assembled from the read endpoints instead.

Add a second one-row table called Sync State with a Next Offset number field and a Done checkbox. Airtable automations are short-lived, so the sync has to remember where it stopped.

2. Run script, not Scripting extension

Two places in Airtable run JavaScript, and they do not behave the same:

  1. In an automation's Run script action the code executes server-side, so the global fetch reaches api.ofapis.com directly. A scheduled sync belongs here.
  2. In the Scripting extension the code runs in your browser, so a cross-origin call needs remoteFetchAsync. Use it for a one-off backfill or to eyeball a response with output.table().

Put the token in the action's Input variables, not in the script body, so it is not readable by every collaborator who opens the automation:

  1. Open Automations → + → Trigger: At scheduled time and set it to every hour.
  2. Add an action and choose Run script.
  3. In Input variables, add ofapisKey, accountId, and startOffset mapped from the Sync State record's Next Offset.
  4. Paste the script below and test it before switching the automation on.

3. The sync script, with upsert and a checkpoint

Reads take limit (default 20, clamped 1–100) and offset (default 0), and the body is wrapped: {"data": {"list": [...], "hasMore": …, "nextOffset": …}}. Airtable caps createRecordsAsync and updateRecordsAsync at 50 records per call, and a Run script action is cut off at roughly 30 seconds — so the script takes a few pages, writes in chunks of 50, saves its offset and stops.

const { ofapisKey, accountId, startOffset } = input.config();
const fans = base.getTable('Fans');
const state = base.getTable('Sync State');
const existing = await fans.selectRecordsAsync({ fields: ['Fan ID'] });
const byFanId = new Map(existing.records.map(r => [r.getCellValue('Fan ID'), r.id]));

let offset = Number(startOffset) || 0;
let hasMore = true;

for (let page = 0; page < 3 && hasMore; page++) {
  const res = await fetch(
    `https://api.ofapis.com/api/public/v1/accounts/${accountId}/subscribers` +
    `?type=active&limit=100&offset=${offset}`,
    { headers: { Authorization: `Bearer ${ofapisKey}` } }
  );
  if (res.status === 429) { console.log(`throttled, retry in ${res.headers.get('retry-after')}s`); break; }
  if (!res.ok) { const e = await res.json(); throw new Error(e.error?.code ?? res.status); }

  const { data } = await res.json();
  const creates = [], updates = [];
  for (const f of data.list) {
    const cells = {
      'Fan ID': String(f.id), 'Username': f.username, 'Name': f.name,
      'Avatar': f.avatar, 'Subscribed': !!f.subscribedOn, 'Last Synced': new Date(),
    };
    const hit = byFanId.get(String(f.id));
    hit ? updates.push({ id: hit, fields: cells }) : creates.push({ fields: cells });
  }
  while (creates.length) await fans.createRecordsAsync(creates.splice(0, 50));
  while (updates.length) await fans.updateRecordsAsync(updates.splice(0, 50));

  hasMore = data.hasMore;
  offset = data.nextOffset ?? offset + 100;
}

await state.updateRecordAsync(
  (await state.selectRecordsAsync()).records[0].id,
  { 'Next Offset': hasMore ? offset : 0, 'Done': !hasMore }
);

Matching on Fan ID before writing is what makes this an upsert rather than a duplicate factory, and resetting the offset to 0 when hasMore turns false is what makes the next cycle start over and pick up changes.

4. Two rate ceilings, not one

A sync has to live under Airtable's write limits and the ofapis read limit at the same time.

On the ofapis side every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (unix seconds). The budget is per token per minute: 60 on Free, 100 on Starter, 500 on Pro. Three pages an hour is nowhere near it, but a script that loops while (hasMore) over a 5,000-fan roster will reach it — the second reason to cap the loop. A 429 adds Retry-After in seconds; inside a 30-second script, log it and stop rather than sleep. Context is on rate limits.

On the Airtable side, the batch of 50 is the limit that actually bites, and it is why the script splices rather than looping one createRecordAsync per fan.

Error bodies are {"error": {"code", "message"}}: expect 401 UNAUTHENTICATED, 402 INSUFFICIENT_CREDITS, 403 NO_ACTIVE_SUBSCRIPTION, 409 IDEMPOTENCY_CONFLICT and 429 RATE_LIMIT_EXCEEDED. A malformed query parameter comes back as 400 with a plain string body instead of that envelope, which is why e.error?.code is optional-chained above. The full table is on error codes.

5. Writes from a button field need an Idempotency-Key

A Button field that runs an automation is the natural way to DM from a record — the obvious use being a greeting sent the moment a new fan appears in the base — and it is also the easiest thing in Airtable to click twice. Every POST accepts an Idempotency-Key header; repeat the key and ofapis replays the first response with Idempotency-Replayed: true and bills nothing extra.

const { ofapisKey, accountId, fanId, text } = input.config();
await fetch(`https://api.ofapis.com/api/public/v1/accounts/${accountId}/chats/${fanId}/messages`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${ofapisKey}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': `welcome-${fanId}`,
  },
  body: JSON.stringify({ text, price: 0, mediaFiles: [] }),
});

Key it off the record, never off Date.now(). The same pattern writes a segment: POST /accounts/{accountId}/lists/{listId}/users with {"userIds": [98765]} moves a fan into a fan list when a Status field changes, and the full route set is in the API documentation.

Worked example: a fan CRM that keeps itself current

  1. Hourly automation → Run script: the sync above, three pages per run, upserting on Fan ID and checkpointing the offset.
  2. Grid view grouped by Subscribed: active fans and lapsed ones side by side, with Last Synced showing how fresh each row is.
  3. "Send welcome" button field on the Fans table, wired to the POST script in step 5 with an idempotency key of welcome-<Fan ID>.
  4. Record-matches-conditions automation: when a fan's Status flips to VIP, a Run script action adds their id to a VIP list so the next campaign targets the segment instead of the whole roster.
  5. Notes and owner fields the API never touches — the part of a CRM that is genuinely yours, because the fan record the API returns stops at identity and subscription state.

Each read is one credit on success, so an hourly three-page sync costs about 72 credits a day whether or not anything changed. Failures are not uniformly free: a 429 or a 5xx costs nothing, but a malformed call that comes back 400 or 404 is charged. If that is more than you want to spend, drop the automation to every four hours; the data is no less correct, just older. The wider case for running the roster on a schedule instead of by hand is the same whichever tool holds the table.

FAQ

Do I use fetch or remoteFetchAsync?

Use fetch in an automation's Run script action, because that code runs server-side, and remoteFetchAsync in the Scripting extension, because that code runs in your browser where a cross-origin fetch is blocked. Same endpoint and same header either way.

Which fields does a subscriber record actually have?

id, username, name, avatar and subscribedOn, inside a data.list array alongside hasMore and nextOffset. Build your table around those; a spend or last-seen column has no source to fill it from.

How do I finish a sync that hits the 30-second script limit?

Checkpoint it. Fetch a fixed number of pages per run, write the next offset into a one-row state table, and let the hourly trigger resume from there. Reset the offset to zero when hasMore comes back false.

Can a button field send a DM straight from a record?

Yes — a Button field runs an automation whose Run script action POSTs to the messaging route, with the fan id passed in as an input variable. Add an Idempotency-Key built from that record so a second click replays the first send.

Does the hourly sync cost credits even when nothing changed?

Yes. Billing is per call, not per changed row, so three pages an hour is three credits an hour regardless of what came back. A throttled 429 or a 5xx costs nothing, but a malformed request answered with 400 or 404 is charged even though it failed.

Related: Google Sheets · Make.com · all integrations

Related guides

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