Bot API

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

Quick Navigation

Overview

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

Create bots directly from the OSHI app with a Telegram BotFather-like wizard

Token Auth

Each bot gets a unique token for secure API access

HTTP API

Simple REST API - send messages from any language with a single POST request

Python SDK

Official Python SDK with OshiBot class for quick integration

Long Polling

getUpdates delivers commands and button taps, acknowledged by offset

MoltBot

Bridge class for MoltBot framework integration

Inline Keyboards

Buttons under a message: callbacks, links, and Mini App launchers

Mini Apps

Open a web app from a button, with signed initData identifying the user

What Your Bot Can and Cannot See

Correcting earlier documentation

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:

  1. A user sends a slash command addressed to your bot — /weather Paris
  2. A user taps a button your bot itself rendered — an inline keyboard callback

Ordinary group conversation never reaches your bot

Not 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.

If you know Telegram

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.

What this rules in and out

Bot ideaWorks?Why
Command bot (/price btc)YesThe user addressed the bot directly
Menus, wizards, formsYesDriven by taps on the bot's own buttons
Alerts, broadcasts, reportsYesOutbound only — /api/bot/send on your own schedule
Mini App (shop, dashboard, booking)YesLaunched from a button, authenticated by signed initData
Welcome messagesPartlyNo join event is delivered. Trigger it yourself from /api/bot/add-member, or have the user run a command
Keyword auto-responderNoPlain messages are never delivered to a bot
Moderation, spam filtering, profanityNoRequires reading everyone's messages; the server cannot decrypt them
Chat logging, analytics on conversationNoSame reason. This is a property of the product, not a gap in the API

Why it is built this way

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.

Getting Started

Follow these 4 steps to create a bot and start sending messages:

1

Create a Bot

Open OSHI app > Portal > Bots > tap "+" to create a new bot

2

Copy Token

After creation, copy the bot token shown on the success screen

3

Assign to Group

Add the bot to a group from Bot Settings > Assigned Groups

4

Send Messages

Use the API, Python SDK, or curl to send messages via your token

5

Declare Commands

Call setMyCommands so users can discover what your bot answers to

6

Poll for Updates

Loop on getUpdates to receive those commands and button taps

Base URL

https://oshi-messenger.com

Keep Your Token Secret

Your bot token grants full access to send messages. Never share it publicly or commit it to version control.

The token is also your Mini App signing key

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.

Receiving Updates

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.

GET /api/bot/getUpdates
ParameterTypeRequiredDescription
tokenstringYesBot token
offsetintegerNoAcknowledges every update below this id and returns the rest. Pass last_update_id + 1.
limitintegerNoMax updates to return. Default and maximum 100.
timeoutintegerNoSeconds to hold the connection open while the queue is empty. Capped at 50. 0 (default) returns immediately.

Acknowledgement is destructive — do not guess an offset

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.

Update shapes

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.

Polling loop

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"

Queue limits

  • Up to 500 updates are kept per bot; beyond that the oldest are dropped
  • An unclaimed update expires after 24 hours
  • The queue is in memory. If the server restarts, pending updates are gone — the user simply taps again. Your commands and Mini App configuration do survive a restart.
POST /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.

ParameterTypeRequiredDescription
tokenstringYesBot token
callbackQueryIdstringYesThe callback_query.id from the update
textstringNoToast text shown to the user
showAlertbooleanNotrue for a modal alert instead of a toast
toPublicKeystringNoWho to show it to — pass callback_query.from.public_key

Without toPublicKey nothing is displayed

The 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.

POST /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.

ParameterTypeRequiredDescription
botIdstringOne ofThe bot's public id. What a normal group member uses.
tokenstringOne ofThe bot's secret token. Only the owner's device has this.
kindstringYescommand or callback_query. Nothing else is accepted.
groupIdstringNoGroup the invocation happened in
fromPublicKeystringWith botIdInvoking user's public key. Checked against the bot's groups.
textstringFor commandMust start with /, otherwise 400
callbackDatastringFor callback_queryThe button's callback_data
messageIdstringNoMessage the button was attached to

Why a button tap does not carry your token

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.

Inline Keyboards

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" } } ]
  ]
}

Button types

FieldEffect when tappedConstraint
callback_dataSends your bot a callback_query updateMax 64 bytes (bytes, not characters)
urlOpens a link. Your bot is not told.Must be https://
web_appOpens a Mini App. Your bot is not told.Must be https://

Limits

LimitValue
Rows10
Buttons per row8
Button text64 characters
callback_data64 bytes
Actions per buttonExactly 1 of callback_data, url, web_app

Validated at send time, on purpose

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.

Sending one

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" } } ]
      ]
    }
  }'

Mini Apps

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.

1. Register the Mini App

POST/api/bot/setMiniApp
ParameterTypeRequiredDescription
tokenstringYesBot token
urlstringYesMust be https://
namestringNoDisplay 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.

2. The client asks for a session

POST/api/bot/miniapp/session

The 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..." }

3. The initData scheme

Identical to Telegram's Web App algorithm, with the same field names:

  1. Take every field except hash as key=value
  2. Sort those lines by key and join them with \n — the data-check string
  3. secret = HMAC_SHA256(key="WebAppData", message=bot_token)
  4. hash = HMAC_SHA256(key=secret, message=data_check_string), hex encoded
FieldMeaning
user_public_keyPublic key of the user who opened the Mini App. Your user identity.
chat_idGroup it was opened from. Empty if none.
bot_nameYour bot's display name
auth_dateUnix seconds when it was signed
hashThe HMAC above

initData expires 24 hours after auth_date. Expired data fails validation even when the signature is perfect.

4. Validate it — server-side, every time

POST/api/bot/miniapp/validate

Never trust initData that has not been validated

initData 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.

A Complete Bot

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.

Before you run it

  • Create the bot in the OSHI app and copy its token
  • Assign it to a group, and copy that Group ID
  • The bot answers /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()

The two mistakes that cost an afternoon

  • Waiting for messages that never come. If your loop sits silent while people talk in the group, it is not broken. Plain messages are never delivered. Type /shop.
  • Not advancing the offset. Forget it and every poll returns the same updates forever, so the bot answers the same command in a loop.

The Mini App backend

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)

What the broken version looks like

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 Endpoints

POST /api/bot/send

Send a message to a group via bot token. This is the primary endpoint for bot messaging.

Request Body

ParameterTypeRequiredDescription
tokenstringYesBot token from OSHI app
groupIdstringYesUUID of the target group. Find it in OSHI App → Group → Info → Group ID (tap to copy)
contentstringConditionalMessage text or caption. Required unless mediaData is provided.
mediaTypestringNoMedia type: photo, video, audio, or document
mediaDatastringNoBase64-encoded file data (max 10 MB raw size)
mediaFileNamestringNoOriginal filename (e.g., report.pdf). Required when mediaData is provided.
replyMarkupobjectNoInline keyboard attached to the message. See Inline Keyboards. Rejected with 400 if malformed.
Where to find your credentials
Bot Token — Shown after bot creation, or in Bot Settings → API section. Tap to copy.
Group ID — Open the group in OSHI → tap group name → Group Info → Group ID. Tap "Copy ID".

Request

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"
  }'

Response

{ "success": true, "delivered": 3, "totalMembers": 3, "messageId": "67a37679-9cc7-4d78-bdf9-ffadcb6fa0ef", "groupName": "My Channel", "hasMedia": false }
POST /api/bot/register

Register a bot with the server. Automatically called by the OSHI app, but can also be called manually for programmatic registration.

Request Body

ParameterTypeRequiredDescription
tokenstringYesBot token
botNamestringYesDisplay name for the bot
ownerPublicKeystringYesCreator's public key
groupsarrayNoGroups 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"]
    }]
  }'
GET /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"

Response

{ "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" } } }
POST /api/bot/update-groups

Update the bot's group assignments.

ParameterTypeRequiredDescription
tokenstringYesBot token
groupsarrayYesNew groups: [{id, name, members}]
GET /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"
POST /api/bot/setMyCommands

Declare the commands your bot answers to, so users can discover them. Stored against your bot and preserved across server restarts.

ParameterTypeRequiredDescription
tokenstringYesBot token
commandsarrayYes[{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"}
    ]
  }'
GET /api/bot/getMyCommands?token=YOUR_TOKEN
{ "success": true, "commands": [ { "command": "shop", "description": "Browse the catalogue" }, { "command": "status", "description": "Service status" } ] }
POST /api/bot/setMiniApp
GET /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.

POST /api/bot/add-member

Add a single member to one of the bot's groups, without resending the whole group list.

ParameterTypeRequiredDescription
tokenstringYesBot token
groupIdstringYesGroup to add them to. 403 if the bot is not assigned to it.
memberPublicKeystringYesThe member's public key

Adding someone already present is a no-op and still returns 200, with botsUpdated: 0.

DELETE /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"

Python SDK

The official Python SDK provides a clean OshiBot class for easy integration. Only requires the requests library.

Installation

bashpip install requests
# Download the SDK
curl -O https://oshi-messenger.com/sdk/oshi_bot.py

Quick Start

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)")

Sending Media

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!")

Command Line

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 SDK is send-only

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.

Code Examples

Send a Message

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')}")

Alert Bot Example

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)

Send Media (Photo / Document)

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"
})

Send a Message

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"
  }'

Register a Bot

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": []
  }'

Check Bot Info

bashcurl "https://oshi-messenger.com/api/bot/info?token=YOUR_BOT_TOKEN"

Send a File

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\"
  }"

Node.js / JavaScript

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`);

Node.js with axios

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);

Send a File (Node.js)

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"
  })
});

Go

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"])
}

Send a File

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),
)

Media & Files

Bots can send images, videos, audio, and documents alongside text messages. Media is sent as Base64-encoded data within the JSON payload.

Supported Media Types

TypeValueExtensionsMax Size
photo"photo"PNG, JPG, GIF, WebP, HEIC10 MB
video"video"MP4, MOV, AVI10 MB
audio"audio"MP3, M4A, WAV, OGG10 MB
document"document"PDF, TXT, DOCX, CSV, any10 MB

Size Limit

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%).

How It Works

  1. Read the file and encode it as Base64
  2. Include mediaType, mediaData, and mediaFileName in the request body
  3. The content field serves as an optional caption
  4. OSHI clients render the media inline with the caption

Encryption & Security

All messages in OSHI benefit from the same encryption pipeline used for user messages. Bot messages are encrypted before being stored on IPFS.

How Bot Messages Are Secured

  1. TLS in transit — Bot sends message via HTTPS to /api/bot/send
  2. Server queues — Message is queued for each group member
  3. AES-256-GCM encryption — OSHI client encrypts the message with a derived group key
  4. IPFS storage — Encrypted payload is pinned to IPFS via Pinata for decentralized delivery
  5. Client decryption — Only group members can derive the key and decrypt

Encrypted Envelope (v2)

Messages 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:

  • Message content, sender key, recipient keys
  • Timestamp, group ID, message chain index
  • Media type and attachment data

Encryption Details

  • Algorithm: AES-256-GCM (authenticated encryption with associated data)
  • Key derivation: SHA-256 HKDF from shared group secret + salt
  • Nonce: Unique random nonce per message (prevents replay attacks)
  • Integrity: GCM authentication tag prevents tampering
  • Storage: IPFS (decentralized, content-addressed, immutable)

MoltBot Integration

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)

Bot Types

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:

Command

Answers slash commands addressed to it, via getUpdates

Webhook

Your service receives an external event and calls /api/bot/send

⏰

Scheduled

Sends messages on a timer (daily reports, reminders)

Mini App

A button opens your web app, authenticated by signed initData

The “Moderator” type has been removed

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.

Rate Limits

LimitValueDescription
Messages/minute60Per bot token, sliding window
Request body15 MBMax JSON payload size (includes Base64-encoded media)
Media file10 MBMax raw file size per media attachment
Content lengthNo limitMessage content has no character limit
Long-poll timeout50 sMaximum getUpdates hold; higher values are clamped
Updates per poll100Default and maximum limit
Queued updates500Per bot; oldest are dropped beyond this
Update lifetime24 hUnclaimed updates expire
initData lifetime24 hAfter auth_date; expired data fails validation
Commands per bot100Names [a-z0-9_], 1–32 chars

Auto-Retry

The Python SDK automatically retries once after a 5-second wait when rate limited. Set auto_retry=False to disable.

Error Handling

HTTP CodeErrorCause
400Bad RequestMissing required fields (token, groupId), missing both content and mediaData, a malformed replyMarkup, an invalid command name, or command text not starting with /
401UnauthorizedInvalid bot token. Register the bot first.
401UnauthorizedMini App initData with a bad signature, a tampered field, an expired auth_date, or signed for a different bot
403ForbiddenBot not assigned to this group
403ForbiddenDispatch or Mini App session by botId from someone who is not a member of a group the bot serves
404Not FoundBot not found (for info/unregister)
404Not FoundUnknown callback_query_id (already expired, or issued for a different bot), or no Mini App configured
429Too Many RequestsRate limit exceeded (60/min)

Two silent failures worth knowing

  • answerCallback without toPublicKey returns 200 and shows the user nothing
  • getUpdates that returns an empty list forever is usually correct behaviour, not a fault — a bot only hears from users who address it

Python Error Handling

pythonfrom 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")