Two-way bots: commands, inline keyboards, callbacks and Mini Apps — on a messenger where a bot only ever sees what a user deliberately sends it
The OSHI Bot API lets you build bots that both send and receive. A bot registers commands, polls getUpdates for the commands and button taps addressed to it, replies with inline keyboards, and can open a Mini App — the same shapes as Telegram's Bot API, so existing muscle memory transfers.
The one place OSHI deliberately differs is what a bot is allowed to see. Group conversation never reaches a bot, and that is not a setting. Read that section first — it decides which bots are buildable here.
Create bots directly from the OSHI app with a Telegram BotFather-like wizard
Each bot gets a unique token for secure API access
Simple REST API - send messages from any language with a single POST request
Official Python SDK with OshiBot class for quick integration
getUpdates delivers commands and button taps, acknowledged by offset
Bridge class for MoltBot framework integration
Buttons under a message: callbacks, links, and Mini App launchers
Open a web app from a button, with signed initData identifying the user
Previous versions of this page described keyword triggers and bots that “respond to messages in group chats”. That was never true on OSHI and is not possible. If you built against it, that is the afternoon this section is trying to give you back. There is no keyword trigger, no message firehose, and no setting that enables one.
A bot receives an update in exactly two situations, and no others:
/weather ParisNot filtered, not redacted, not rate-limited — absent. Messages between members are end-to-end encrypted; the server relays ciphertext it cannot read. Forwarding group chat to a bot is not a feature that was left switched off, it is a thing the server is not able to do.
Telegram calls this privacy mode, and there it is a toggle a bot owner can turn off to receive all group messages. In OSHI it is not a toggle. The narrow behaviour is the only behaviour. Ported Telegram code that relies on reading group messages will receive nothing — and it will look like a broken poll loop rather than a permissions error, which is why it is called out here.
| Bot idea | Works? | Why |
|---|---|---|
Command bot (/price btc) | Yes | The user addressed the bot directly |
| Menus, wizards, forms | Yes | Driven by taps on the bot's own buttons |
| Alerts, broadcasts, reports | Yes | Outbound only — /api/bot/send on your own schedule |
| Mini App (shop, dashboard, booking) | Yes | Launched from a button, authenticated by signed initData |
| Welcome messages | Partly | No join event is delivered. Trigger it yourself from /api/bot/add-member, or have the user run a command |
| Keyword auto-responder | No | Plain messages are never delivered to a bot |
| Moderation, spam filtering, profanity | No | Requires reading everyone's messages; the server cannot decrypt them |
| Chat logging, analytics on conversation | No | Same reason. This is a property of the product, not a gap in the API |
A bot in a group is an extra party in the room. On a messenger whose entire promise is that no one outside the conversation can read it, a bot that could read everything would quietly break that promise for every member of the group — usually without them realising a bot was present. Making the narrow behaviour the only behaviour means a bot cannot become a surveillance channel, whoever installs it.
Follow these 4 steps to create a bot and start sending messages:
Open OSHI app > Portal > Bots > tap "+" to create a new bot
After creation, copy the bot token shown on the success screen
Add the bot to a group from Bot Settings > Assigned Groups
Use the API, Python SDK, or curl to send messages via your token
Call setMyCommands so users can discover what your bot answers to
Loop on getUpdates to receive those commands and button taps
https://oshi-messenger.com
Your bot token grants full access to send messages. Never share it publicly or commit it to version control.
Mini App initData is signed with a key derived from your bot token. Anyone holding the token can forge a session for any user. Keep it server-side: never ship it in a Mini App's front-end bundle, and never send it to the browser.
Your bot receives work by long-polling getUpdates. Updates are held in a per-bot queue on the server and acknowledged by offset, exactly like Telegram: you confirm you have handled everything up to update_id by asking for update_id + 1 next time.
/api/bot/getUpdates
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| offset | integer | No | Acknowledges every update below this id and returns the rest. Pass last_update_id + 1. |
| limit | integer | No | Max updates to return. Default and maximum 100. |
| timeout | integer | No | Seconds to hold the connection open while the queue is empty. Capped at 50. 0 (default) returns immediately. |
Passing an offset discards every update below it, whether or not you ever saw them. Passing an arbitrarily high offset to “skip to the end” throws away the update you are waiting for. Only ever send back an id you actually received.
Each update carries update_id plus exactly one of message or callback_query.
{
"success": true,
"result": [
{
"update_id": 1,
"message": {
"message_id": "m-1",
"chat": { "id": "GROUP_UUID" },
"from": { "public_key": "senders_public_key" },
"text": "/weather Paris",
"date": 1789500000
}
},
{
"update_id": 2,
"callback_query": {
"id": "b3f1c0de-...",
"from": { "public_key": "senders_public_key" },
"data": "buy:sku-42",
"message": { "message_id": "m-1", "chat": { "id": "GROUP_UUID" } }
}
}
]
}message.text always begins with / — a non-command message is rejected at dispatch and never becomes an update. See What Bots Can See.
bash# First call: no offset, take whatever is queued
curl "https://oshi-messenger.com/api/bot/getUpdates?token=YOUR_BOT_TOKEN&timeout=50"
# Then acknowledge by asking for the next id
curl "https://oshi-messenger.com/api/bot/getUpdates?token=YOUR_BOT_TOKEN&offset=3&timeout=50"
/api/bot/answerCallback
Acknowledge a button tap. Telegram's answerCallbackQuery. Call it for every callback_query you receive, even when you have nothing to say: it marks the callback answered server-side, and it is the only way to show the user a toast.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| callbackQueryId | string | Yes | The callback_query.id from the update |
| text | string | No | Toast text shown to the user |
| showAlert | boolean | No | true for a modal alert instead of a toast |
| toPublicKey | string | No | Who to show it to — pass callback_query.from.public_key |
toPublicKey nothing is displayedThe call still returns {"success": true}, the callback is still marked answered, and the user sees no toast at all. If your alert text never appears, this is why.
A callback id belongs to the bot it was issued for. Answering another bot's callback returns 404 Unknown callback_query_id.
/api/bot/dispatch
Called by the OSHI client, not by your bot. This is the endpoint the app hits when a user invokes a bot, and it is the only thing that creates an update. It is documented so the model is inspectable — if you are writing a bot you will never call it.
| Parameter | Type | Required | Description |
|---|---|---|---|
| botId | string | One of | The bot's public id. What a normal group member uses. |
| token | string | One of | The bot's secret token. Only the owner's device has this. |
| kind | string | Yes | command or callback_query. Nothing else is accepted. |
| groupId | string | No | Group the invocation happened in |
| fromPublicKey | string | With botId | Invoking user's public key. Checked against the bot's groups. |
| text | string | For command | Must start with /, otherwise 400 |
| callbackData | string | For callback_query | The button's callback_data |
| messageId | string | No | Message the button was attached to |
Tapping a button has to tell the server which bot to wake. It cannot do that with the bot's token: the token is a bearer credential, and handing it to every member of a group would let any of them post as the bot. So messages your bot sends carry botId — a one-way hash of the token that names the bot without authenticating as it.
A caller using botId may only act as themselves: the server checks fromPublicKey is a member of a group the bot serves, and answers 403 otherwise. This is handled by the app; bot authors do not need to do anything.
The 400 on text that does not start with / is the privacy rule expressed in code: there is no request shape that turns an ordinary message into an update.
Pass replyMarkup to /api/bot/send to attach buttons to a message. The shape mirrors Telegram's.
json{
"inline_keyboard": [
[ { "text": "Buy", "callback_data": "buy:sku-42" },
{ "text": "Docs", "url": "https://example.com/docs" } ],
[ { "text": "Open Shop", "web_app": { "url": "https://shop.example.com/app" } } ]
]
}
| Field | Effect when tapped | Constraint |
|---|---|---|
| callback_data | Sends your bot a callback_query update | Max 64 bytes (bytes, not characters) |
| url | Opens a link. Your bot is not told. | Must be https:// |
| web_app | Opens a Mini App. Your bot is not told. | Must be https:// |
| Limit | Value |
|---|---|
| Rows | 10 |
| Buttons per row | 8 |
| Button text | 64 characters |
callback_data | 64 bytes |
| Actions per button | Exactly 1 of callback_data, url, web_app |
A malformed keyboard is rejected with 400 and a message naming the problem, rather than being delivered and failing to render on someone else's phone — a failure you would never see. Giving a button two actions, or an http:// URL, is a 400, not a silent repair.
bashcurl -X POST https://oshi-messenger.com/api/bot/send \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "What would you like to do?",
"replyMarkup": {
"inline_keyboard": [
[ { "text": "Buy", "callback_data": "buy:sku-42" } ],
[ { "text": "Open Shop", "web_app": { "url": "https://shop.example.com/app" } } ]
]
}
}'
A Mini App is your own web page, opened inside OSHI from a web_app button. OSHI hands it a signed initData string that identifies the user who opened it, so your page can log them in without asking them to sign in again.
/api/bot/setMiniApp| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| url | string | Yes | Must be https:// |
| name | string | No | Display name, max 64 chars. Defaults to the bot's name. |
bashcurl -X POST https://oshi-messenger.com/api/bot/setMiniApp \
-H "Content-Type: application/json" \
-d '{"token":"YOUR_BOT_TOKEN","url":"https://shop.example.com/app","name":"Shop"}'
# read it back
curl "https://oshi-messenger.com/api/bot/getMiniApp?token=YOUR_BOT_TOKEN"
This is stored against your bot and survives a server restart. Set it once.
/api/bot/miniapp/sessionThe OSHI app calls this when the user taps your web_app button. It returns the URL to open plus the signed initData to hand to the page. Your bot token stays on the server; the Mini App never sees it.
Like /dispatch, it accepts either the bot's token (owner) or its public botId (any member of a group the bot serves, proving themselves with userPublicKey; non-members get 403). Bot authors do not call this — the app does.
{
"success": true,
"url": "https://shop.example.com/app",
"initData": "auth_date=1789500000&bot_name=Shop+Bot&chat_id=GROUP_UUID&user_public_key=abc...&hash=e2b371..."
}Identical to Telegram's Web App algorithm, with the same field names:
hash as key=value\n — the data-check stringsecret = HMAC_SHA256(key="WebAppData", message=bot_token)hash = HMAC_SHA256(key=secret, message=data_check_string), hex encoded| Field | Meaning |
|---|---|
| user_public_key | Public key of the user who opened the Mini App. Your user identity. |
| chat_id | Group it was opened from. Empty if none. |
| bot_name | Your bot's display name |
| auth_date | Unix seconds when it was signed |
| hash | The HMAC above |
initData expires 24 hours after auth_date. Expired data fails validation even when the signature is perfect.
/api/bot/miniapp/validateinitData arrives through the browser, which means it arrives through the user. It is a string in a web page: anyone can edit user_public_key to someone else's and reload. Parsing it in front-end JavaScript and believing what it says is authentication bypass — that is one edited string away from acting as any user of your Mini App.
Send the raw string to /api/bot/miniapp/validate from your backend and use only the data it returns. The signature is what makes the fields trustworthy, and checking it requires the bot token, which belongs on your server. Validate on every request that matters, not once at page load.
bashcurl -X POST https://oshi-messenger.com/api/bot/miniapp/validate \
-H "Content-Type: application/json" \
-d '{"token":"YOUR_BOT_TOKEN","initData":"<the raw string, unmodified>"}'
{
"success": true,
"data": {
"user_public_key": "abc...",
"chat_id": "GROUP_UUID",
"bot_name": "Shop Bot",
"auth_date": "1789500000"
}
}A bad signature, a tampered field, an expired auth_date, or initData signed for a different bot all return 401. Pass the string through byte for byte — re-encoding or re-ordering the parameters changes the data-check string and the signature will not match.
One runnable script that exercises the whole surface: it declares a command, long-polls for updates, answers with an inline keyboard, handles the button tap, and offers a Mini App. Only requests is needed.
/shop. Nothing else in the group reaches it.python#!/usr/bin/env python3
"""A complete OSHI bot: command -> inline keyboard -> callback -> Mini App."""
import requests, time
API = "https://oshi-messenger.com"
TOKEN = "YOUR_BOT_TOKEN"
GROUP = "YOUR_GROUP_UUID"
MINI_APP = "https://shop.example.com/app"
def api(method, path, **kw):
r = requests.request(method, API + path, timeout=70, **kw)
r.raise_for_status()
return r.json()
# --- one-time setup -------------------------------------------------------
# Both of these persist on the server; you do not need to repeat them at every
# start-up, but doing so is harmless and keeps the bot self-describing.
api("POST", "/api/bot/setMyCommands", json={
"token": TOKEN,
"commands": [{"command": "shop", "description": "Browse the catalogue"}],
})
api("POST", "/api/bot/setMiniApp", json={
"token": TOKEN, "url": MINI_APP, "name": "Shop",
})
# --- handlers -------------------------------------------------------------
def send(text, markup=None):
body = {"token": TOKEN, "groupId": GROUP, "content": text}
if markup:
body["replyMarkup"] = markup
return api("POST", "/api/bot/send", json=body)
def on_command(msg):
text = msg.get("text", "")
# The text is addressed to this bot; strip any /command@botname form.
cmd = text.split()[0].lstrip("/").split("@")[0].lower()
if cmd == "shop":
send("What would you like to do?", markup={"inline_keyboard": [
[{"text": "Today's deal", "callback_data": "deal"},
{"text": "Catalogue", "url": "https://shop.example.com/catalogue"}],
[{"text": "Open Shop", "web_app": {"url": MINI_APP}}],
]})
else:
# Unknown command. Worth answering: the user typed it deliberately.
send(f"I don't know /{cmd}. Try /shop")
def on_callback(cb):
# ALWAYS answer, even with no text: this clears the pending state on the
# button. toPublicKey is what makes the toast visible - without it the call
# succeeds and the user sees nothing.
api("POST", "/api/bot/answerCallback", json={
"token": TOKEN,
"callbackQueryId": cb["id"],
"text": "Fetching today's deal...",
"toPublicKey": cb.get("from", {}).get("public_key"),
})
if cb.get("data") == "deal":
send("Today: 20% off everything.", markup={"inline_keyboard": [
[{"text": "Open Shop", "web_app": {"url": MINI_APP}}],
]})
# --- the polling loop -----------------------------------------------------
def main():
offset = 0 # 0 = "give me whatever is queued"; never invent a value
while True:
try:
# timeout=50 is a LONG POLL: the server holds the connection until
# an update arrives or 50s pass. It is not a delay - it is how you
# get instant delivery without hammering the API.
params = {"token": TOKEN, "timeout": 50, "limit": 100}
if offset:
params["offset"] = offset
updates = api("GET", "/api/bot/getUpdates", params=params)["result"]
except requests.RequestException as e:
print("poll failed, retrying:", e)
time.sleep(5)
continue
for u in updates:
# Advance the offset BEFORE handling, and outside the try below, so
# one poisonous update cannot wedge the loop redelivering forever.
offset = u["update_id"] + 1
try:
if "message" in u:
on_command(u["message"])
elif "callback_query" in u:
on_callback(u["callback_query"])
except Exception as e:
print("handler error on update", u["update_id"], e)
if __name__ == "__main__":
main()
/shop.The page opened by the web_app button receives initData. Here is the only correct way to use it — validated server-side, never parsed and trusted in the browser:
pythonfrom flask import Flask, request, jsonify
import requests
app = Flask(__name__)
API = "https://oshi-messenger.com"
TOKEN = "YOUR_BOT_TOKEN" # server-side ONLY. Never sent to the browser.
def current_user(init_data: str):
"""Return the verified user, or None. The ONLY source of identity."""
r = requests.post(f"{API}/api/bot/miniapp/validate",
json={"token": TOKEN, "initData": init_data}, timeout=10)
if r.status_code != 200:
return None # bad signature, tampered, or expired
return r.json()["data"]["user_public_key"]
@app.post("/api/orders")
def create_order():
# The front end sends the raw initData string it was handed, UNMODIFIED.
user = current_user(request.headers.get("X-Init-Data", ""))
if not user:
return jsonify(error="unauthenticated"), 401
# `user` is now trustworthy. Anything the browser claimed about identity
# is not, and is ignored.
return jsonify(ok=True, buyer=user)
This is the bug to avoid, and it looks perfectly reasonable:
javascript// WRONG - this is an authentication bypass, not a login.
const params = new URLSearchParams(initData);
const user = params.get("user_public_key"); // attacker edits this to anyone
showDashboardFor(user);
initData reaches the page through the user's own browser. Reading the fields without checking the signature means trusting a string the user can rewrite. Always send it to /api/bot/miniapp/validate from your backend, and use only what comes back.
/api/bot/send
Send a message to a group via bot token. This is the primary endpoint for bot messaging.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token from OSHI app |
| groupId | string | Yes | UUID of the target group. Find it in OSHI App → Group → Info → Group ID (tap to copy) |
| content | string | Conditional | Message text or caption. Required unless mediaData is provided. |
| mediaType | string | No | Media type: photo, video, audio, or document |
| mediaData | string | No | Base64-encoded file data (max 10 MB raw size) |
| mediaFileName | string | No | Original filename (e.g., report.pdf). Required when mediaData is provided. |
| replyMarkup | object | No | Inline keyboard attached to the message. See Inline Keyboards. Rejected with 400 if malformed. |
curlcurl -X POST https://oshi-messenger.com/api/bot/send \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Hello from my bot"
}'
{
"success": true,
"delivered": 3,
"totalMembers": 3,
"messageId": "67a37679-9cc7-4d78-bdf9-ffadcb6fa0ef",
"groupName": "My Channel",
"hasMedia": false
}/api/bot/register
Register a bot with the server. Automatically called by the OSHI app, but can also be called manually for programmatic registration.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| botName | string | Yes | Display name for the bot |
| ownerPublicKey | string | Yes | Creator's public key |
| groups | array | No | Groups to assign: [{id, name, members}] |
curlcurl -X POST https://oshi-messenger.com/api/bot/register \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"botName": "My Alert Bot",
"ownerPublicKey": "your_public_key",
"groups": [{
"id": "group-uuid",
"name": "Alerts",
"members": ["member1_key", "member2_key"]
}]
}'
/api/bot/info?token=YOUR_TOKEN
Get bot info, stats, and assigned groups.
curlcurl "https://oshi-messenger.com/api/bot/info?token=YOUR_BOT_TOKEN"
{
"success": true,
"bot": {
"botName": "My Alert Bot",
"registeredAt": "2026-02-20T03:52:44.684Z",
"groups": [
{ "id": "uuid", "name": "Alerts", "memberCount": 5 }
],
"stats": { "messagesSent": 42, "lastActivity": "2026-02-20T12:00:00Z" }
}
}/api/bot/update-groups
Update the bot's group assignments.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| groups | array | Yes | New groups: [{id, name, members}] |
/api/bot/list
Return your own bot and its stats. Requires your bot token — this endpoint no longer lists other people's bots.
curlcurl "https://oshi-messenger.com/api/bot/list?token=YOUR_BOT_TOKEN"
/api/bot/setMyCommands
Declare the commands your bot answers to, so users can discover them. Stored against your bot and preserved across server restarts.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| commands | array | Yes | [{command, description}]. Max 100. Send [] to clear. |
A leading / is stripped for you. Names must match [a-z0-9_], 1–32 characters — anything else is a 400 naming the offending command. Descriptions are truncated at 256 characters.
bashcurl -X POST https://oshi-messenger.com/api/bot/setMyCommands \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"commands": [
{"command": "/shop", "description": "Browse the catalogue"},
{"command": "status", "description": "Service status"}
]
}'
/api/bot/getMyCommands?token=YOUR_TOKEN
{
"success": true,
"commands": [
{ "command": "shop", "description": "Browse the catalogue" },
{ "command": "status", "description": "Service status" }
]
}/api/bot/setMiniApp
/api/bot/getMiniApp?token=YOUR_TOKEN
Register the Mini App URL for your bot, or read back what is registered. Also preserved across restarts. Full detail in Mini Apps.
/api/bot/add-member
Add a single member to one of the bot's groups, without resending the whole group list.
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | Bot token |
| groupId | string | Yes | Group to add them to. 403 if the bot is not assigned to it. |
| memberPublicKey | string | Yes | The member's public key |
Adding someone already present is a no-op and still returns 200, with botsUpdated: 0.
/api/bot/unregister?token=YOUR_TOKEN
Remove a bot from the server registry.
curlcurl -X DELETE "https://oshi-messenger.com/api/bot/unregister?token=YOUR_BOT_TOKEN"
The official Python SDK provides a clean OshiBot class for easy integration. Only requires the requests library.
bashpip install requests
# Download the SDK
curl -O https://oshi-messenger.com/sdk/oshi_bot.py
pythonfrom oshi_bot import OshiBot
# Initialize with your bot token
bot = OshiBot(token="YOUR_BOT_TOKEN")
# Send a message to a group
result = bot.send("GROUP_UUID", "Hello from Python!")
print(f"Delivered to {result['delivered']} members")
# Get bot info
info = bot.info()
print(f"Bot: {info['bot']['botName']}")
print(f"Messages sent: {info['bot']['stats']['messagesSent']}")
# Send to all assigned groups
results = bot.send_to_all_groups("Broadcast message!")
# Get groups list
groups = bot.get_groups()
for g in groups:
print(f" {g['name']} ({g['memberCount']} members)")
python# Send an image with caption
bot.send_image("GROUP_UUID", "/path/to/chart.png", caption="Daily chart")
# Send a document
bot.send_document("GROUP_UUID", "/path/to/report.pdf", caption="Q4 Report")
# Send any file (auto-detects type from extension)
bot.send_file("GROUP_UUID", "/path/to/data.csv")
# Low-level: send raw base64 media
import base64
with open("photo.jpg", "rb") as f:
data = base64.b64encode(f.read()).decode()
bot.send_media("GROUP_UUID", "photo", data, "photo.jpg", content="Check this!")
bash# Send a message
python oshi_bot.py send YOUR_TOKEN GROUP_ID "Hello!"
# Get bot info
python oshi_bot.py info YOUR_TOKEN
# List groups
python oshi_bot.py groups YOUR_TOKEN
# Check stats
python oshi_bot.py stats YOUR_TOKEN
# Show your own bot (this no longer lists other people's bots)
python oshi_bot.py list YOUR_TOKEN
The OshiBot class predates two-way bots and covers send, info and groups. There is no get_updates helper yet. For commands, callbacks and Mini Apps, call the endpoints directly — A Complete Bot is a full working loop in plain requests.
pythonimport requests
response = requests.post("https://oshi-messenger.com/api/bot/send", json={
"token": "YOUR_BOT_TOKEN",
"groupId": "550e8400-e29b-41d4-a716-446655440000",
"content": "Market update: BTC +2.5% today"
})
data = response.json()
if data["success"]:
print(f"Delivered to {data['delivered']} members")
else:
print(f"Error: {data.get('error')}")
pythonfrom oshi_bot import OshiBot
import schedule, time
bot = OshiBot(token="YOUR_BOT_TOKEN")
GROUP = "your-group-uuid"
def send_daily_report():
report = generate_report() # your logic
bot.send(GROUP, f"Daily Report\n{report}")
schedule.every().day.at("09:00").do(send_daily_report)
while True:
schedule.run_pending()
time.sleep(60)
pythonimport base64, requests
# --- Send a photo ---
with open("chart.png", "rb") as f:
img_b64 = base64.b64encode(f.read()).decode()
requests.post("https://oshi-messenger.com/api/bot/send", json={
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Daily chart",
"mediaType": "photo",
"mediaData": img_b64,
"mediaFileName": "chart.png"
})
# --- Send a document ---
with open("report.pdf", "rb") as f:
doc_b64 = base64.b64encode(f.read()).decode()
requests.post("https://oshi-messenger.com/api/bot/send", json={
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Monthly report",
"mediaType": "document",
"mediaData": doc_b64,
"mediaFileName": "report.pdf"
})
bashcurl -X POST https://oshi-messenger.com/api/bot/send \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Hello from curl"
}'
bashcurl -X POST https://oshi-messenger.com/api/bot/register \
-H "Content-Type: application/json" \
-d '{
"token": "YOUR_BOT_TOKEN",
"botName": "My Bot",
"ownerPublicKey": "your_public_key",
"groups": []
}'
bashcurl "https://oshi-messenger.com/api/bot/info?token=YOUR_BOT_TOKEN"
bash# Encode file and send as media
FILE_B64=$(base64 -i chart.png)
curl -X POST https://oshi-messenger.com/api/bot/send \
-H "Content-Type: application/json" \
-d "{
\"token\": \"YOUR_BOT_TOKEN\",
\"groupId\": \"GROUP_UUID\",
\"content\": \"Daily chart\",
\"mediaType\": \"photo\",
\"mediaData\": \"$FILE_B64\",
\"mediaFileName\": \"chart.png\"
}"
javascript// Send a message with fetch
const response = await fetch("https://oshi-messenger.com/api/bot/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: "YOUR_BOT_TOKEN",
groupId: "GROUP_UUID",
content: "Hello from JavaScript!"
})
});
const data = await response.json();
console.log(`Delivered to ${data.delivered} members`);
javascriptconst axios = require("axios");
const { data } = await axios.post("https://oshi-messenger.com/api/bot/send", {
token: "YOUR_BOT_TOKEN",
groupId: "GROUP_UUID",
content: "Hello from Node.js!"
});
console.log(data);
javascriptconst fs = require("fs");
const fileData = fs.readFileSync("report.pdf").toString("base64");
const response = await fetch("https://oshi-messenger.com/api/bot/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: "YOUR_BOT_TOKEN",
groupId: "GROUP_UUID",
content: "Monthly report",
mediaType: "document",
mediaData: fileData,
mediaFileName: "report.pdf"
})
});
gopackage main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func main() {
payload, _ := json.Marshal(map[string]string{
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Hello from Go!",
})
resp, err := http.Post(
"https://oshi-messenger.com/api/bot/send",
"application/json",
bytes.NewBuffer(payload),
)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var result map[string]interface{}
json.NewDecoder(resp.Body).Decode(&result)
fmt.Printf("Delivered: %v\n", result["delivered"])
}
gofileBytes, _ := os.ReadFile("report.pdf")
fileB64 := base64.StdEncoding.EncodeToString(fileBytes)
payload, _ := json.Marshal(map[string]string{
"token": "YOUR_BOT_TOKEN",
"groupId": "GROUP_UUID",
"content": "Monthly report",
"mediaType": "document",
"mediaData": fileB64,
"mediaFileName": "report.pdf",
})
resp, _ := http.Post(
"https://oshi-messenger.com/api/bot/send",
"application/json",
bytes.NewBuffer(payload),
)
Bots can send images, videos, audio, and documents alongside text messages. Media is sent as Base64-encoded data within the JSON payload.
| Type | Value | Extensions | Max Size |
|---|---|---|---|
| photo | "photo" | PNG, JPG, GIF, WebP, HEIC | 10 MB |
| video | "video" | MP4, MOV, AVI | 10 MB |
| audio | "audio" | MP3, M4A, WAV, OGG | 10 MB |
| document | "document" | PDF, TXT, DOCX, CSV, any | 10 MB |
Media data is limited to 10 MB (raw file size, before Base64 encoding). The total request body limit is 15 MB to accommodate Base64 overhead (~33%).
mediaType, mediaData, and mediaFileName in the request bodycontent field serves as an optional captionAll messages in OSHI benefit from the same encryption pipeline used for user messages. Bot messages are encrypted before being stored on IPFS.
/api/bot/sendMessages stored on IPFS are wrapped in an encrypted envelope. Only the version number and a short routing hint are visible:
{
"v": 2,
"envelope": "aGVsbG8gd29ybGQ...",
"hint": "a1b2",
"nonce": "dW5pcXVlbm9uY2U..."
}Hidden inside the encrypted envelope:
The OSHI SDK includes an OshiMoltBotBridge class that acts as a transport/output plugin for the MoltBot multi-platform bot framework.
pythonfrom oshi_bot import OshiMoltBotBridge
# Initialize bridge
bridge = OshiMoltBotBridge(
oshi_token="YOUR_BOT_TOKEN",
default_group="GROUP_UUID"
)
# Send to default group
bridge.send("Hello from MoltBot!")
# Send to specific group
bridge.send("Alert!", group_id="other-group-uuid")
# Broadcast to all groups
bridge.broadcast("System maintenance in 5 minutes")
# Use as MoltBot output plugin
class MyMoltBot:
def __init__(self):
self.outputs = [bridge]
def on_event(self, event):
message = f"Event: {event['type']} - {event['data']}"
for output in self.outputs:
output.send(message)
Four shapes that work well on OSHI. Each is driven by a user acting deliberately, or by your own schedule — never by reading group conversation:
Answers slash commands addressed to it, via getUpdates
Your service receives an external event and calls /api/bot/send
Sends messages on a timer (daily reports, reminders)
A button opens your web app, authenticated by signed initData
This page previously listed a Moderator bot doing “rules enforcement” and automatic welcome messages. Moderation requires reading everyone's messages, which is not possible here — see What Bots Can See. Welcomes have no join event to hang off; drive them from your own call to /api/bot/add-member, or have new members run a command.
| Limit | Value | Description |
|---|---|---|
| Messages/minute | 60 | Per bot token, sliding window |
| Request body | 15 MB | Max JSON payload size (includes Base64-encoded media) |
| Media file | 10 MB | Max raw file size per media attachment |
| Content length | No limit | Message content has no character limit |
| Long-poll timeout | 50 s | Maximum getUpdates hold; higher values are clamped |
| Updates per poll | 100 | Default and maximum limit |
| Queued updates | 500 | Per bot; oldest are dropped beyond this |
| Update lifetime | 24 h | Unclaimed updates expire |
| initData lifetime | 24 h | After auth_date; expired data fails validation |
| Commands per bot | 100 | Names [a-z0-9_], 1–32 chars |
The Python SDK automatically retries once after a 5-second wait when rate limited. Set auto_retry=False to disable.
| HTTP Code | Error | Cause |
|---|---|---|
| 400 | Bad Request | Missing required fields (token, groupId), missing both content and mediaData, a malformed replyMarkup, an invalid command name, or command text not starting with / |
| 401 | Unauthorized | Invalid bot token. Register the bot first. |
| 401 | Unauthorized | Mini App initData with a bad signature, a tampered field, an expired auth_date, or signed for a different bot |
| 403 | Forbidden | Bot not assigned to this group |
| 403 | Forbidden | Dispatch or Mini App session by botId from someone who is not a member of a group the bot serves |
| 404 | Not Found | Bot not found (for info/unregister) |
| 404 | Not Found | Unknown callback_query_id (already expired, or issued for a different bot), or no Mini App configured |
| 429 | Too Many Requests | Rate limit exceeded (60/min) |
answerCallback without toPublicKey returns 200 and shows the user nothinggetUpdates that returns an empty list forever is usually correct behaviour, not a fault — a bot only hears from users who address itpythonfrom oshi_bot import OshiBot, OshiAuthError, OshiGroupError, OshiRateLimitError
bot = OshiBot(token="YOUR_TOKEN")
try:
bot.send("GROUP_ID", "Hello!")
except OshiAuthError:
print("Invalid token - register the bot first")
except OshiGroupError:
print("Bot not assigned to this group")
except OshiRateLimitError:
print("Slow down - rate limit exceeded")