OnlyFans Telegram Bot
There is no first-party ofapis app for Telegram, and OnlyFans has no official bot either. What you build is a relay: one HTTPS handler that ofapis posts events to, which then calls the Telegram Bot API. Both sides are JSON over HTTPS, so it fits in a single serverless function.
This page builds the solo-creator case. A new fan DM lands on your phone as a Telegram message with a one-tap reply button, and tapping it sends a real OnlyFans DM back — no dashboard, no laptop.
What the relay actually does
Before it talks to Telegram at all, the relay has three jobs.
It verifies X-Ofapis-Signature, of the form t=<unix>,v1=<hex>. The v1 value is HMAC-SHA256 over the literal string <t>.<raw request body>, keyed with the endpoint's signing secret — 32 random bytes, base64url-encoded, shown once at creation. Hash the raw bytes you received; re-serialising the parsed JSON changes whitespace and the comparison fails.
It rejects a stale t — older than five minutes — so a captured body cannot be replayed at you later.
It dedupes on X-Ofapis-Delivery, a UUID that stays constant across retries. A non-2xx or a timeout is retried at 1m, 5m, 30m, 2h and 6h, six attempts, and after ten consecutive failures the endpoint is disabled outright. So a handler that calls Telegram before returning 200 and then times out will push the same fan message to your phone five more times. Return 200 first, queue the work second.
The signature-checking code is written out in full in the webhook setup guide; the rest of this page is the Telegram half.
Step 1 — BotFather, token, chat id
Message @BotFather, run /newbot, keep the token. Every call is https://api.telegram.org/bot<TOKEN>/<method>. That token is a bearer credential sitting in a URL path, so it belongs in an environment variable and never in a log line; /revoke in BotFather invalidates a leaked one.
Telegram will not message you until you message it first. Send the bot any text, then read the chat id out of the update queue:
curl -s "https://api.telegram.org/bot$TG_TOKEN/getUpdates" \
| jq '.result[-1].message.chat.id'
# 448213907
Step 2 — getUpdates or setWebhook
getUpdates is long polling: hold a request open with timeout=50, then acknowledge each batch by passing offset=<last update_id + 1> on the next call. No public URL needed, which suits a laptop prototype, but it needs a process that stays alive.
setWebhook is push, and it is what you want once the relay exists, because one function then handles both inbound sources:
curl -s -X POST "https://api.telegram.org/bot$TG_TOKEN/setWebhook" \
-d url=https://relay.example.com/telegram \
-d secret_token=$TG_SECRET \
-d 'allowed_updates=["message","callback_query"]'
Telegram then stamps every delivery with X-Telegram-Bot-Api-Secret-Token, which you compare against TG_SECRET — a second shared secret, unrelated to the ofapis one. The two modes are exclusive: after setWebhook, getUpdates errors until you call deleteWebhook.
Step 3 — event in, sendMessage out
message.received is the only event type ofapis emits today. Its body is small:
{
"type": "message.received",
"occurred_at": 1760880421,
"data": {
"chatId": 98765,
"message": {
"id": 444555666,
"text": "hey are you around tonight?",
"fromUser": { "id": 98765 },
"toUser": { "id": 12345678 },
"createdAt": "2025-06-01T10:05:00+00:00"
}
}
}
Two things to internalise. text is nullable — media with no caption produces null, and passing that to sendMessage returns 400 Bad Request: message text is empty. And fromUser carries an id and nothing else, no username or avatar; for a handle in the alert, spend a credit on GET /users/{targetUserId} — the fan lookup that answers "who is this" — and cache it.
data.chatId is the conversation to reply into, and on the REST side a conversation id is the fan's OnlyFans user id — so for an inbound message it should equal data.message.fromUser.id. Assert that, and prefer fromUser.id if they disagree.
import html, os, requests
TG = f"https://api.telegram.org/bot{os.environ['TG_TOKEN']}"
def alert(event):
m = event["data"]["message"]
chat_id = m.get("fromUser", {}).get("id") or event["data"]["chatId"]
body = m.get("text") or "(media, no caption)"
requests.post(f"{TG}/sendMessage", json={
"chat_id": int(os.environ["TG_OWNER_CHAT"]),
"text": f"<b>New DM</b> <code>{chat_id}</code>\n{html.escape(body)[:3500]}",
"parse_mode": "HTML",
"reply_markup": {"inline_keyboard": [[
{"text": "Be right there", "callback_data": f"r:{chat_id}:brt"},
{"text": "Thanks love", "callback_data": f"r:{chat_id}:thx"},
]]},
}, timeout=5)
Use HTML, not MarkdownV2: fan-authored text routinely contains ., - and !, all of which MarkdownV2 reserves, and one unescaped character returns a 400 instead of your alert. A message caps at 4096 characters, hence the slice. callback_data caps at 64 bytes, which is why the button carries a short code rather than the reply text — r: plus an eleven-digit id plus a three-letter key is about 17 bytes.
Step 4 — the button round-trip
A tap arrives at the same webhook as a callback_query update. Telegram spins the button until you call answerCallbackQuery, so acknowledge first, then do the slow part:
CANNED = {"brt": "Be right there 😊", "thx": "Thanks love, that made my day"}
OF = "https://api.ofapis.com/api/public/v1"
def on_callback(cq):
requests.post(f"{TG}/answerCallbackQuery",
json={"callback_query_id": cq["id"]}, timeout=5)
_, chat_id, key = cq["data"].split(":")
r = requests.post(f"{OF}/chats/{chat_id}/messages",
headers={"Authorization": f"Bearer {os.environ['OFAPIS_KEY']}"},
json={"text": CANNED[key]}, timeout=15)
note = "sent ✅" if r.ok else f"failed: {r.json()['error']['code']}"
requests.post(f"{TG}/sendMessage",
json={"chat_id": cq["message"]["chat"]["id"], "text": note})
The ofapis key never touches Telegram. Telegram transports a 17-byte opcode; the relay holds the credential and decides what it means. Swap CANNED for a template lookup and you have the front end of an auto-reply bot.
Cost, limits and what breaks
POST /chats/{chatId}/messages costs 1 credit. The account-scoped form, POST /accounts/{id}/chats/{chatId}/messages, costs 2 — a solo creator with one linked account does not need it. The Free plan's 250 monthly credits cover building and testing; see pricing before leaving it running.
Failures do not bill uniformly. A 424 with OF_SESSION_EXPIRED means the OnlyFans session needs re-linking: the credit is debited then refunded, and retrying is pointless until someone re-authenticates. A 400 BAD_REQUEST — an empty text, for instance — is charged and not refunded, so validate first. Full list in the error reference.
Rate limits sit on both sides. ofapis allows 60 requests per minute per token on Free and 100 on Starter, and every response carries X-RateLimit-Remaining and X-RateLimit-Reset so you can back off before the 429 — see rate limits. Telegram's guidance is roughly one message per second to the same chat, so a forty-DM burst hits Telegram's throttle long before ofapis's; batch several fans into one digest message.
For a shared team inbox rather than one person's phone, the thread-per-fan pattern on the Slack integration fits better; an agency with a dozen creators usually wants the channel-per-creator Discord build instead, where each creator's traffic gets its own rate-limit budget.
FAQ
Can one Telegram bot handle several creators?
Yes, without running multiple bots. Route on data.message.toUser.id, the creator who received the DM, and map each creator id to a Telegram chat or forum topic. The relay then calls the account-scoped routes described in multi-account management.
What stops someone forging a webhook into my bot?
The signature. Without the endpoint's signing secret an attacker cannot produce a valid v1 for a body they chose, and a rejected request never reaches sendMessage. Add the timestamp check so an old but genuinely-signed body cannot be replayed, and keep the Telegram secret_token check on the other direction.
Do I have to host anything?
One HTTPS function, free tier is fine. Both directions are short request/response cycles with no state, so a Cloudflare Worker or a Lambda function URL is enough. Long polling is the only mode that needs a process you keep alive.
Can the bot send PPV or media, not just text?
Yes. POST /chats/{chatId}/messages accepts lockedText, price in cents and mediaFiles as an array of vault media ids, so an inline button can fire a priced unlock as easily as a canned line. Pull the ids first from the vault media endpoint.
What happens if my relay is down for an hour?
The retry ladder absorbs it: the message arrives late, not never. Past six attempts the delivery is marked failed, and after ten consecutive failures ofapis disables the endpoint until you re-enable it in the dashboard. Create a free key and fire the dashboard's test delivery to confirm the path before relying on it.