OnlyFans Slack Integration
Nobody ships a first-party OnlyFans app for Slack, ofapis included. You write a relay: a small HTTPS service that verifies X-Ofapis-Signature, discards repeats by X-Ofapis-Delivery, and then calls Slack. This page builds it for a chatter team — every fan conversation becomes one Slack thread, replies go out from a button inside that thread, and the thread itself is the audit trail of who said what.
Incoming Webhooks run out of road here
Slack gives you two ways in, and the threading requirement decides between them.
An Incoming Webhook (https://hooks.slack.com/services/...) is a channel-bound URL that takes a JSON body and answers with the literal string ok. No message id comes back. Without a ts you cannot thread, so an incoming webhook can only ever produce a flat stream of alerts.
chat.postMessage on the Web API takes a bot token with the chat:write scope and returns {"ok":true,"channel":"C…","ts":"1760880421.000300"}. That ts is the handle for everything else: pass it as thread_ts and the next message lands under the first one. Use the Web API.
One constraint to design around first: message.received is the only event type ofapis emits today. Threads are built from inbound fan DMs. Tips, subscriptions and purchases do not arrive this way, so anything else you want in the thread has to come from a poll on your own schedule.
The relay, in full
This half is identical whatever platform you feed, so here it is in full; the Telegram and Discord pages take it as given and spend their space on the platform side instead. Creating the endpoint and retrieving its signing secret is covered in the webhook setup guide, and the same handler written as a general-purpose Node client works for anything else you bolt on later.
import crypto from "node:crypto";
const seen = new Map(); // deliveryId → expiry
export async function handler(req, rawBody) { // rawBody is a Buffer
const header = req.headers["x-ofapis-signature"] || "";
const t = header.match(/t=(\d+)/)?.[1];
const v1 = header.match(/v1=([0-9a-f]+)/)?.[1];
if (!t || !v1) return { status: 400 };
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return { status: 400 };
const expected = crypto
.createHmac("sha256", process.env.OFAPIS_WEBHOOK_SECRET)
.update(`${t}.${rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
if (!ok) return { status: 400 };
const id = req.headers["x-ofapis-delivery"];
if (seen.has(id)) return { status: 200 }; // retry of something done
seen.set(id, Date.now() + 86_400_000);
queue.push(JSON.parse(rawBody)); // ack now, work later
return { status: 200 };
}
Three details carry their weight. The HMAC is over `${t}.${rawBody}` using the raw bytes — parse the JSON after you verify, because JSON.stringify(JSON.parse(x)) is not x. timingSafeEqual throws on length mismatch, so guard it if you accept malformed headers. And the dedupe window must outlive the retry ladder, which runs 1m, 5m, 30m, 2h, 6h: an in-process Map loses its contents on redeploy, so use Redis or a table the moment this matters.
One thread per fan
Keep a ledger from chatId to the parent message's ts. chatId is the fan's OnlyFans user id, so it is stable across sessions and across chatters.
async function intoSlack(event) {
const { chatId, message } = event.data;
let ts = await ledger.get(chatId);
if (!ts) { // first contact: open a thread
const parent = await slack("chat.postMessage", {
channel: process.env.INBOX_CHANNEL,
text: `Conversation with ${chatId}`,
blocks: [
{ type: "section", text: { type: "mrkdwn",
text: `*Fan* \`${chatId}\` · creator \`${message.toUser?.id}\`` } },
{ type: "actions", block_id: `fan_${chatId}`, elements: [
{ type: "button", action_id: "reply_open", style: "primary",
text: { type: "plain_text", text: "Reply" }, value: String(chatId) },
]},
],
});
ts = parent.ts;
await ledger.set(chatId, ts);
}
await slack("chat.postMessage", {
channel: process.env.INBOX_CHANNEL,
thread_ts: ts,
text: message.text || "_(media, no caption)_",
});
}
The parent message carries the identifiers and the button; every inbound DM becomes a reply underneath it. A bare numeric id is a poor thing to greet someone by, so most teams resolve it once with the fan record endpoint and cache the username into the parent text. Six weeks later the whole relationship is one scrollable thread with timestamps, which is the thing a flat alert channel can never give you.
chat.postMessage is throttled to roughly one message per second per channel with a small burst allowance. A promo night across thirty fans will brush that, so drain the queue serially rather than firing the whole batch in parallel.
The reply button
Interactive components need a Request URL set under Interactivity in your app config. Slack POSTs application/x-www-form-urlencoded with a single payload field, expects a 200 within three seconds, and signs the request itself — X-Slack-Signature is v0= plus HMAC-SHA256 over v0:{timestamp}:{raw body} with the app's signing secret. That is a second, independent verification from the ofapis one, with its own secret and its own prefix; do not let the two share a code path.
Open a modal on the click, then on submit send the DM and write the result back into the same thread:
const r = await fetch(
`https://api.ofapis.com/api/public/v1/chats/${chatId}/messages`,
{ method: "POST",
headers: { Authorization: `Bearer ${process.env.OFAPIS_KEY}`,
"Content-Type": "application/json" },
body: JSON.stringify({ text }) });
const reqId = r.headers.get("X-Request-Id");
const body = await r.json();
await slack("chat.postMessage", {
channel: process.env.INBOX_CHANNEL,
thread_ts: await ledger.get(chatId),
text: r.ok
? `<@${slackUserId}> sent · of msg \`${body.data.id}\` · req \`${reqId}\``
: `<@${slackUserId}> failed · \`${body.error.code}\` · req \`${reqId}\``,
});
Every ofapis response echoes X-Request-Id, and the success envelope is {"data": …} while a failure is {"error":{"code","message"}}. Logging the Slack user, the OnlyFans message id and the request id into the thread is what makes the audit trail real: a disputed message can be traced from the Slack click to the API call to the DM the fan saw.
Backfilling and what it costs
A chatter opening a cold thread usually wants history. GET /chats/{chatId}/messages pages with limit — default 20, server-clamped to 1–100 — and walks backwards with the olderThanMessageId cursor rather than an offset, so paging stays correct while new messages arrive. Each page is 1 credit; so is each send on the unscoped route. Budget around that: see chat API for the shapes and pricing for the per-plan allowance.
Error handling has a cost dimension chatters will not think about. A 401, 402, 403 or 429 is refused before metering and costs nothing. A 400 BAD_REQUEST or a 404 ACCOUNT_NOT_FOUND is charged and not refunded, so validate the modal input before it reaches the API; a 409, a 424 and any 5xx are debited and then refunded. The error reference lists all of them, and sign up gets you a key to test against.
FAQ
Can a chatter reply from the thread without clicking a button?
Yes, with a second listener. Subscribe the app to message.channels events, ignore anything without a thread_ts, look the thread_ts back up in the ledger to recover the chatId, and forward the text. It feels more natural than a modal, but you lose the confirmation step, so most teams keep the button for anything priced.
Does this need a published Slack app?
No. An app installed only to your own workspace has every capability used here — bot token, Block Kit, interactivity. Directory publication is a distribution step, not a technical one, and it brings a review process you do not need for an internal ops tool.
How do I stop two chatters replying to the same fan at once?
Use the thread as the lock. On click, immediately post a short "picked up by @name" reply and disable the button by updating the parent message with chat.update; release it after the send resolves. Slack's own presence is not enough, because two people can open the modal in the same second.
Can I route several creators into separate channels?
Yes — the ledger key becomes (toUser.id, chatId) and the channel is chosen from message.toUser.id, the creator who received the DM. One token and one endpoint can serve all of them through the account-scoped routes in multi-account management. If you want a channel per creator with role pings rather than threads in one inbox, Discord is laid out that way.
What should the relay return if Slack is down?
Still 200. Acknowledge ofapis, keep the event in your own queue, and retry against Slack on your own schedule. Returning an error passes the problem to the ofapis retry ladder, and ten consecutive failures disable the endpoint — a Slack outage would then silently cost you the integration.