Bot API

A bot is a Mandarin account that a program runs. It has a Mandarin ID, a profile and a public username, and people talk to it the way they talk to anybody: in a direct chat, or in a group or a channel it is added to. Your program drives it over a plain HTTP API, the Bot API: it takes what happens to the bot as updates, and answers with methods.

If you have written a Telegram bot, most of this will feel familiar.

Quick start

1. Make a bot

Write to @bots, the system bot, from any Mandarin app — even a classic ICQ client will do. Send /newbot, then the name your bot goes by, then its username: 5 to 32 Latin letters, digits and underscores, starting with a letter and ending in bot, like weather_bot or TetrisBot.

@bots answers with the bot's link, its ID and its token:

123456789:4f1c9a2e7b8d3f6a0c5e9b1d7a3f8c2e6b0d4a9f1e5c7b3d8a2f6e0c4b9d1a7e

The token is the bot's password: whoever has it controls the bot. Keep it out of your code and your repository. If it leaks, send /token to @bots to replace it; the old one stops working at once.

2. Call a method

Every method is a POST to https://api.mandarin.im/bot/<token>/<method> with a JSON body. Check that the token works:

curl -X POST https://api.mandarin.im/bot/$TOKEN/getMe
{
  "ok": true,
  "result": {
    "user_id": 123456789,
    "nick": "Weather",
    "bot": true,
    "bot_reads": "addressed",
    "name": "weather_bot",
    "link": "https://mandarin.im/weather_bot",
    "joins_groups": true
  }
}

3. Answer messages

An echo bot in Python, with nothing to install. It asks for updates, waiting up to 50 seconds for one to come, and says back whatever text it is sent:

import json
import os
import urllib.error
import urllib.request

API = f"https://api.mandarin.im/bot/{os.environ['TOKEN']}/"


def call(method, **params):
    request = urllib.request.Request(
        API + method, data=json.dumps(params).encode(),
        headers={"Content-Type": "application/json"})
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            answer = json.load(response)
    except urllib.error.HTTPError as error:
        answer = json.load(error)
    if not answer["ok"]:
        raise RuntimeError(answer["error"])
    return answer["result"]


offset = 0
while True:
    for update in call("getUpdates", offset=offset, timeout=50):
        offset = update["update_id"] + 1
        if update["type"] != "message.created":
            continue
        message = update["data"]
        if message.get("kind") == "text":
            call("sendMessage", conversation_id=message["conversation_id"],
                 text=message["payload"]["text"])

Run it with TOKEN=... python3 echo.py, open your bot's link and write to it.

Making requests

Every answer is JSON, except getFile's, which is the file itself. A success is

{"ok": true, "result": ...}

and a failure is

{"ok": false, "error": {"code": "rate_limited", "description": "Too many requests, slow down", "retry_after": 3}}

with an HTTP status that matches the code:

Code HTTP status Means
invalid_format 400 A parameter is missing, malformed or out of its limits
invalid_state 400 The request does not fit what it acts on, such as editing a voice message
invalid_token 401 The token is unknown or was replaced
not_authorized 403 The bot may not do this here, such as posting where it is a read-only member
not_found, user_not_found 404 No such method, conversation, message, member or file the bot can see
conflict 409 The message changed since the revision given, an operation_id was used for something else, or getUpdates was called while a webhook is set
rate_limited 429 Too fast: wait retry_after seconds, also given as the Retry-After header
not_supported 501 The server does not do this
internal_error 500 Something broke on the server; try again later

Retrying safely

Methods that write — sendMessage, editMessage, deleteMessage, sendFile, leaveConversation — take an operation_id of up to 128 characters. Calls with the same operation_id are one operation: if a request times out and you retry it with the same operation_id, the message is still sent only once, and the retry returns it. So give every new operation an operation_id of its own, such as a random UUID: a call that reuses one returns what the first call did, whatever its parameters, and one that reuses it for another method fails with conflict. Without an operation_id, every call is a new operation.

Getting updates

Everything a bot is told of comes as an update:

{
  "update_id": 42,
  "type": "message.created",
  "conversation_id": "direct:12345:67890",
  "time": "2026-10-13T09:00:00Z",
  "data": {
    "msg_id": "512", "conversation_id": "direct:12345:67890", "from": 12345,
    "kind": "text", "payload": {"text": "/start"},
    "time": "2026-10-13T09:00:00Z", "revision": 1, "reactions": []
  }
}
Field Description
update_id The update's number. Numbers only grow, but need not be consecutive.
type What happened; see update types.
conversation_id The conversation it happened in.
time When it happened.
data What it is about: the message, the conversation, the press.

A bot takes updates in one of two ways: it asks for them with getUpdates, or has them posted to its webhook. Not both: while a webhook is set, getUpdates fails with conflict.

Long polling with getUpdates

getUpdates returns the updates waiting for the bot, oldest first. With a timeout, it waits up to that many seconds for one to come when none is waiting, so a loop of calls hears of everything at once without hammering the server.

offset confirms what you have handled: every update numbered below it is done and goes. Call with the last update_id you handled plus one. Until you confirm an update, it is returned again, under the same update_id — so a bot that crashes halfway through a batch gets it again after a restart. Update IDs keep growing even if the server's database is restored from a backup.

Updates you never confirm are dropped 24 hours after they happened. So are updates with nothing left to tell, such as those of a conversation the bot was removed from.

Confirming a message is reading it: the people a bot talks with see their messages read once the bot confirms them.

Webhooks

With a webhook set, the server posts each update to your address as soon as it comes, one at a time and in order. Set it with setWebhook:

curl -X POST https://api.mandarin.im/bot/$TOKEN/setWebhook \
  -H 'Content-Type: application/json' \
  -d '{"url": "https://example.com/mandarin/hook", "secret": "s3cr3t-value"}'

deleteWebhook returns the bot to getUpdates, and getWebhookInfo tells where updates go now. A getUpdates still waiting when a webhook is set ends with conflict. Replacing the bot's token deletes its webhook too.

Update types

Type When data
message.created A message the bot is to hear of is written The message
message.edited Such a message is edited The message as it now is
message.deleted Such a message is deleted for everybody conversation_id, and the message, its content gone
message.reacted Somebody reacts to a message the bot hears of; in a group where it hears what is addressed to it, to one of its own The message, with its reactions
member.added, member.removed Somebody joins or leaves a group the bot hears everything of conversation, and the member or members
conversation.created The bot comes to be in a conversation: somebody wrote to it first, or added it to a group or a channel conversation, and by: who added the bot, where somebody did
conversation.deleted The bot is out of a conversation conversation_id, and reason: deleted, left, removed or banned
button.pressed Somebody pressed one of the bot's buttons press_id, from, conversation_id, message, data

A bot is not told of the messages it wrote, edited, deleted or reacted to itself, and never of reads, deliveries, pins or drafts: those are for people's devices.

What a bot hears

In a direct chat, a bot hears everything said to it.

In a group, it hears what its owner chose on its profile, in the bot_reads field, which everybody adding it to a group can see:

Change it with /setreads to @bots. The setting a bot had when it was added to a group holds there; to take up a new one in a group it is already in, it has to be added again.

Conversations

Every conversation — a direct chat, a group or a channel — is named by its conversation_id. Take IDs from updates and never build them yourself: they are opaque, and their form may change.

A bot never owns a group or a channel. getConversation tells what the bot sees of one, and its membership.capabilities say what it may do there: message.send among them means it may post.

Messages

A message as updates and methods return it:

{
  "msg_id": "512",
  "conversation_id": "c_38a2f37d9a17e9230d113bb0f8931b2a",
  "from": 67890,
  "kind": "text",
  "payload": {"text": "Hi @Weather", "mentions": [{"user_id": 123456789, "offset": 3, "length": 8}]},
  "reply_to": {"msg_id": "498", "from": 123456789, "kind": "text", "payload": {"text": "Sunny, +21°"}},
  "time": "2026-10-13T09:00:00Z",
  "revision": 1,
  "reactions": [{"reaction": "👍", "user_ids": [67890]}]
}
Field Description
msg_id The message's ID
conversation_id Where it was written
from Who wrote it, a Mandarin ID
kind text or file
payload For text: text, mentions and smileys. For file: see files.
reply_to The message it answers, in short, if it answers one
forwarded_from Where it was forwarded from, if it was
keyboard The buttons under a bot's message
time, edited_at When it was written, and last edited
revision Grows with every change; editMessage names the one it changes
reactions Who reacted, and how

Sending text

curl -X POST https://api.mandarin.im/bot/$TOKEN/sendMessage \
  -H 'Content-Type: application/json' \
  -d '{"conversation_id": "direct:12345:67890", "text": "Sunny, +21°", "reply_to": "512"}'

A text has up to 4096 characters; send a longer one as several messages. reply_to answers a message of the same conversation.

Mentions address people in a group: each is a range of the text that starts with @, with the user_id of a member. Offsets and lengths count Unicode code points — Python's len() does, JavaScript's .length does not. Ranges go in order and do not overlap; up to 32 per message, none in direct chats.

{"conversation_id": "c_38a2…", "text": "@Alice, it's your turn", "mentions": [{"user_id": 23456, "offset": 0, "length": 6}]}

Smileys are pictures from the server's packs, which getPacks and getPack list. Each is a range of the text that holds the smiley's fallback or one of its codes, with its pack_id and smiley_id; whoever cannot draw it reads the text. Offsets count code points as for mentions; ranges go in order and overlap neither each other nor a mention; up to 100 per message, in any conversation.

{"conversation_id": "direct:12345:67890", "text": "Sunny :-)", "smileys": [{"pack_id": "kolobok", "smiley_id": "smile", "offset": 6, "length": 3}]}

Editing and deleting

editMessage replaces the text, mentions, smileys and buttons of one of the bot's messages. Give the revision you last saw: if the message has changed since, the edit fails with conflict. Buttons left out of an edit are removed.

deleteMessage deletes a message for everybody: one of the bot's own, or anybody's in a group or a channel where the bot is an admin.

Files

sendFile sends a file, up to 50 MB, as multipart/form-data: conversation_id first, then the file with its name, and optionally reply_to and operation_id. A bot sends up to two files at a time.

curl -X POST https://api.mandarin.im/bot/$TOKEN/sendFile \
  -F conversation_id=direct:12345:67890 \
  -F file=@forecast.png

A file message's payload:

{
  "file_id": "4dbf74d3bb384ca0a86b29479a2a45ea",
  "name": "photo.jpg",
  "size": 2048000,
  "content_type": "image/jpeg",
  "available": true,
  "media": {"kind": "image", "state": "ready", "width": 1920, "height": 1080}
}

media, when present, is what the server found the file to be — image, audio or video — and voice marks a voice message, with its duration_ms. getFile with a file_id returns the file's bytes, with its Content-Type and name in Content-Disposition. available is false for a file that was deleted.

Showing activity

sendChatAction shows people that the bot is busy: typing or record_voice. Apps show it for about 5 seconds, or until the bot sends something or calls cancel; repeat it every 4 seconds or so while the bot is still at work.

Buttons

A bot's text message can carry buttons under it, as keyboard: a list of rows, each a list of buttons. A button shows its text and has either

{
  "conversation_id": "c_38a2…",
  "text": "Lunch at noon?",
  "keyboard": [
    [{"text": "Yes", "data": "lunch:yes"}, {"text": "No", "data": "lunch:no"}],
    [{"text": "Menu", "url": "https://example.com/menu"}]
  ]
}

A press of a data button comes as a button.pressed update:

{"update_id": 77, "type": "button.pressed", "conversation_id": "c_38a2…", "time": "2026-10-13T09:01:00Z",
 "data": {"press_id": "p_6f1c2a9e", "from": 67890, "conversation_id": "c_38a2…",
  "message": {"msg_id": "512", "...": "..."}, "data": "lunch:yes"}}

The person's app waits for your answer. Answer with answerButton within 10 seconds: a short text shown for a moment, or as an alert they dismiss. Answer even when there is nothing to say, with no text, so the app stops waiting; after 10 seconds it stops anyway, and the press can no longer be answered. To change the message itself — a tally, a new set of buttons — call editMessage as well.

curl -X POST https://api.mandarin.im/bot/$TOKEN/answerButton \
  -H 'Content-Type: application/json' \
  -d '{"press_id": "p_6f1c2a9e", "text": "Counted"}'

Limits: a button's text is up to 64 characters on one line, a url up to 2048 characters; up to 8 buttons in a row and 100 in a keyboard, taking up to 16 KB as JSON. Classic ICQ clients do not show buttons.

Commands

Tell people which commands your bot takes, and their apps offer them as / is typed:

curl -X POST https://api.mandarin.im/bot/$TOKEN/setCommands \
  -H 'Content-Type: application/json' \
  -d '{"commands": [{"command": "start", "description": "Begin"}, {"command": "forecast", "description": "The weather where you are"}]}'

A command is 1 to 32 Latin lower-case letters, digits and underscores, written after /, once in a list; its description, which it must have, is up to 256 characters on one line; a list has up to 100 commands. The list is for everywhere, unless scope names direct chats or groups, whose own list then takes its place there. An empty list removes a scope's own list. Commands change up to 10 times a minute.

In a group, a command chosen from the list is sent as /forecast@weather_bot, which mentions the bot, so a bot that hears only what is addressed to it hears it too.

Limits

What Limit
Methods 30 calls a second, whatever they are
setWebhook, deleteWebhook 10 a minute
Posting in a direct chat About 1 message a second in each, after a burst of 20
Posting in a group or a channel About 20 messages a minute in each, after a burst of 20
Files at a time 2
File size 50 MB
getUpdates timeout 50 seconds
Updates kept unconfirmed 24 hours
Message text 4096 characters
Bots per owner 20

Going faster is answered rate_limited, with how long to wait in retry_after. Wait that long and try again. A group's slow mode holds a bot back as it holds anybody.

Methods

getMe

The bot's own profile: user_id, nick, about, name, link, bot_reads, joins_groups, and commands as they hold in direct chats and in groups. No parameters.

getUpdates

Parameter Type Description
offset integer Confirms every update numbered below it
limit integer 1–100 updates to return; 100 by default
timeout integer 0–50 seconds to wait when no update is waiting; 0 by default

Returns a list of updates, oldest first.

sendMessage

Parameter Type Description
conversation_id string Where to write; required
text string Up to 4096 characters; required
reply_to string The msg_id this answers
mentions array {user_id, offset, length} ranges, in groups
smileys array {pack_id, smiley_id, offset, length} ranges
keyboard array Buttons under the message
operation_id string Makes a retry the same operation

Returns the message.

editMessage

Parameter Type Description
msg_id string One of the bot's messages; required
text string The new text; required
revision integer The message's revision as the bot last saw it; required
mentions array The new mentions
smileys array The new smileys
keyboard array The buttons the message is to have; left out, none
operation_id string Makes a retry the same operation

Returns the message.

deleteMessage

Parameter Type Description
msg_id string The message to delete for everybody; required
operation_id string Makes a retry the same operation

Returns true.

sendFile

Takes multipart/form-data, not JSON:

Field Description
conversation_id Where to send; required, and before file
file The file, with its name; up to 50 MB; required
reply_to The msg_id this answers
operation_id Makes a retry the same operation

Returns the message.

getFile

Parameter Type Description
file_id string A file in a message the bot can see; required

Returns the file's bytes, not JSON.

sendChatAction

Parameter Type Description
conversation_id string Where; required
action string typing, record_voice or cancel; required

Returns true.

getConversation

Parameter Type Description
conversation_id string A conversation the bot is in; required

Returns the conversation: its kind (direct, group or channel), what everybody sees of it in shared — title, description, member_count — and the bot's own standing in membership: its role and capabilities.

getMember

Parameter Type Description
conversation_id string A group or a channel; required
user_id integer The member; required

Returns the member: user_id, role, nick, joined_at. In a channel, only admins see its members.

leaveConversation

Parameter Type Description
conversation_id string A group or a channel; required
operation_id string Makes a retry the same operation

Returns true.

setWebhook

Parameter Type Description
url string An HTTPS address on port 443, 80, 88 or 8443; required
secret string Up to 256 letters, digits, _ and -, sent back in X-Mandarin-Bot-Secret

Returns true.

deleteWebhook

No parameters. Returns true.

getWebhookInfo

No parameters. Returns {"url": "...", "has_secret": true}, with an empty url when no webhook is set.

answerButton

Parameter Type Description
press_id string From the button.pressed update; required
text string Up to 200 characters shown to whoever pressed
alert boolean Show the text as an alert to dismiss, rather than for a moment

Returns true.

setCommands

Parameter Type Description
commands array {command, description} pairs; empty removes the scope's own list
scope string all (the default), direct or groups

Returns true.

getCommands

Parameter Type Description
scope string all (the default), direct or groups

Returns the scope's own list of commands.

getPacks

No parameters. Returns the smiley packs of the server's catalog: each pack_id, kind (smileys; emoji are drawn by clients and marked by no range), version and title by language.

getPack

Parameter Type Description
pack_id string The pack; required

Returns the pack with its smileys in order: each smiley_id, its fallback, the codes that stand for it too, and the formats its pictures come in, at /smileys/{pack_id}/{smiley_id}/{format}.

Managing bots

Bots are managed by talking to @bots. Where it asks which bot, or which value, it offers buttons too.

Command Does
/newbot Makes a bot, and tells its link, ID and token
/mybots Lists your bots
/token Gives a bot a new token; the old one stops working
/setname Changes the name a bot goes by
/setdescription Changes what a bot says about itself, shown before a chat with it begins
/setuserpic Changes or removes a bot's picture
/setreads Chooses what a bot hears in groups: addressed or all
/setjoingroups Lets anybody add the bot to groups, or nobody
/cancel Stops the command under way

A bot keeps its username for good. One person may own up to 20 bots.