OnlyFans Discord Bot
Discord has no OnlyFans app, and ofapis does not publish one. The piece you write yourself is a relay: an HTTPS endpoint that checks X-Ofapis-Signature, drops anything it has already seen by X-Ofapis-Delivery, and then speaks Discord. The verification half is the same on every platform, and is listed in full in the webhook setup guide — this page is the Discord half, built for the way an OnlyFans agency runs its desk: every creator owns a channel, and the night shift gets pinged when something needs a human. For one creator on one phone, Telegram is the lighter build.
Webhook URL or gateway bot
These are two different products and you will probably use both.
| Incoming Webhook URL | Gateway bot | |
|---|---|---|
| Setup | Channel → Edit Channel → Integrations → Webhooks | Developer portal app + bot token |
| Connection | Stateless HTTPS POST | Persistent WebSocket with heartbeats |
| Can post embeds | Yes | Yes |
| Can be clicked back | No | Yes — slash commands, buttons |
| Throughput | 30 requests/minute per webhook | Shared global bucket, 50/sec |
| Hosting | None | A process that stays connected |
Inbound alerts go through webhook URLs, because a URL per channel costs nothing and needs no uptime. Anything a chatter clicks needs the bot.
Build the channel map
One ofapis endpoint can serve every creator, and message.received is the only event it will ever send you. The field that tells you whose DM it is is data.message.toUser.id — the creator who received the message. Keep a map from that id to a channel's webhook URL and the routing is two lines — the Node setup is the same one the outbound calls later on this page use:
// creator OF user id → that creator's #channel webhook
const CHANNELS = {
12345678: process.env.HOOK_LUNA,
23456789: process.env.HOOK_MAYA,
};
export async function fanout(event) {
const { chatId, message } = event.data;
const url = CHANNELS[message.toUser?.id];
if (!url) return; // unmapped creator: log, don't throw
await post(url, {
username: "ofapis",
allowed_mentions: { parse: [] },
embeds: [{
title: `DM from ${chatId}`,
description: (message.text || "_media, no caption_").slice(0, 4096),
color: 0x00b3ff,
timestamp: new Date(event.occurred_at * 1000).toISOString(),
footer: { text: `msg ${message.id}` },
}],
});
}
Two traps in that block. occurred_at is unix seconds, and Discord's embed.timestamp wants ISO-8601 — hand it the raw integer and the embed silently loses its timestamp. And allowed_mentions: { parse: [] } is deliberate: fan text containing @everyone would otherwise ping your whole server from a webhook that has no permission checks.
Discord's ceilings are worth pinning to the wall: 2,000 characters of content, 4,096 in an embed description, 25 fields, 6,000 across the whole embed, 10 embeds per message. Exceed one and you get a 400 with a field-path error, not a truncated message.
You could instead register one ofapis endpoint per creator — an endpoint can be scoped to a single account at creation — but routing in your own code keeps you to one signing secret and one deploy.
Escalating to the night shift
The reason to use embeds rather than plain text is that an ops channel is read at a glance. The reason to use role mentions is that most messages should not interrupt anyone. Gate the ping:
const ESCALATE = /refund|chargeback|cancel|report/i;
const ping = ESCALATE.test(message.text ?? "")
? { content: `<@&${process.env.ROLE_NIGHT_SHIFT}>`,
allowed_mentions: { parse: [], roles: [process.env.ROLE_NIGHT_SHIFT] } }
: {};
allowed_mentions.roles is an allowlist: the role in content pings, everything else stays inert even if a fan typed it. Keep the escalation rule in config — the words that matter change weekly and agencies want per-creator overrides.
The 30-per-minute ceiling
Each webhook URL is limited to roughly 30 requests per minute. That is the number that decides your architecture. A creator taking 40 DMs an hour never notices; a creator mid-promo taking 200 in ten minutes will blow straight through it, and Discord answers with 429 and a JSON body containing retry_after in seconds. The response headers — X-RateLimit-Remaining, X-RateLimit-Reset-After, X-RateLimit-Bucket — let you throttle before that happens.
The failure mode specific to this setup: do not let a Discord 429 become a non-2xx answer to ofapis. ofapis retries a failed delivery at 1m, 5m, 30m, 2h and 6h, six attempts, and disables the endpoint after ten consecutive failures. If your relay returns 500 because Discord throttled you, a busy hour can disable your webhook endpoint entirely and you will find out when the channel goes quiet. Acknowledge ofapis with 200 immediately, buffer in your own queue, and drain at 25 posts per minute per channel with a coalescing step — five DMs from one fan in thirty seconds belong in one embed, not five.
Because each creator has their own webhook URL, the budget is per channel, not per server. Channel-per-creator is not just tidy; it is how you get headroom. A single shared firehose channel, the layout Slack handles better with threads, would hit the ceiling first.
When the bot earns its keep
Replying needs the gateway bot. Register a slash command, acknowledge inside Discord's 3-second window with a deferred response, call ofapis, then edit the reply — you get 15 minutes to finish:
// /reply fan:98765 text:"on my way"
await fetch(
`https://api.ofapis.com/api/public/v1/accounts/${accountId}/chats/${fan}/messages`,
{ method: "POST",
headers: { Authorization: `Bearer ${process.env.OFAPIS_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": interaction.id },
body: JSON.stringify({ text }) });
Reusing Discord's interaction id as the Idempotency-Key makes your own retries safe. The interaction id does not change, so if the POST times out and you fire it again, ofapis returns the original response with Idempotency-Replayed: true instead of sending the fan a second DM — and does not charge twice. An attempt that overlaps one still in flight gets 409 IDEMPOTENCY_CONFLICT, which the bot should surface as "already sending" rather than retry.
What an agency pays
Inbound alerts are free — webhook deliveries cost no credits. Outbound does: an account-scoped send, POST /accounts/{id}/chats/{chatId}/messages, is 2 credits, twice the unscoped form. Pro is $159 for 120,000 credits, five linked accounts and 500 requests per minute per token, which is the plan a multi-creator server needs; the pricing page has the rest. For the scoped routes see multi-account management, and for the wider ops picture, agency automation.
FAQ
Can I skip the relay and point ofapis straight at a Discord webhook URL?
No, and you would not want to. Discord expects its own JSON shape — content or embeds — and would reject the ofapis event body, and a raw Discord URL cannot verify X-Ofapis-Signature, so anyone who learned the URL could post into your ops channel. The relay is where verification, routing and rate-limit buffering live.
How many creators can one Discord server handle?
Discord's limit is 500 channels per server and 15 webhooks per channel, so the server is not the constraint — your ofapis plan is. Pro allows five linked accounts; beyond that you need multiple tokens or an Enterprise quote.
Do I need the MESSAGE_CONTENT intent?
Only if the bot reads what people type in channels. A bot driven by slash commands and buttons receives its data in the interaction payload, so it does not need the privileged intent and avoids Discord's verification process for it.
What happens to events while my relay is redeploying?
They queue. A delivery that gets no 2xx is retried on the 1m/5m/30m/2h/6h ladder, so a deploy that takes two minutes loses nothing. Watch the delivery log in the dashboard after any deploy — ten consecutive failures auto-disable the endpoint.
Can the bot post scheduled content, not just reply?
Yes, the same token reaches the rest of the API. A /queue command can create a scheduled post for 3 credits, and a /blast command can drive a mass DM through the create → preview → trigger flow, where preview tells you the recipient count and estimated cost before anyone commits. Start on the free plan to wire it up.