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
- Call
https://api.mandarin.im/bot/<token>/<method>withPOST. - Send the parameters as a JSON object in the body, with
Content-Type: application/json. Leave the body out when there are none.sendFilealone takesmultipart/form-data. - A parameter the method does not know is rejected with
invalid_format, so a typo never passes silently.
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"}'
- The address is HTTPS, up to 2048 characters, on port 443, 80, 88 or 8443, with a certificate browsers trust: a self-signed one fails. The server posts only to addresses on the public internet; one in a private network is accepted but never reached.
- Each post is one update object, the same as in
getUpdates, with yoursecretin theX-Mandarin-Bot-Secretheader. Check it, so that nobody else can post fake updates to you. - Any
2xxanswer confirms the update. Answer within 20 seconds, and finish within 30: anything else — an error, a timeout, a redirect — has the update posted again after a pause that grows up to a minute, until it is 24 hours old. An update may so come twice; tell the two apart byupdate_id. - The next update is posted only once the one before it is confirmed.
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:
addressed(the default): messages that mention the bot or reply to one of its messages — commands chosen from its list among them, which mention it (/forecast@weather_bot) — and what is done to its own messages: reactions and deletions. A command typed by hand, without the mention, is not heard.all: everything said there, and who comes and goes.
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.
- Direct chats. A bot answers people; it never writes first. A direct chat
begins when a person writes to the bot, usually by opening its link and
pressing Start, which sends
/start. A link may carry a parameter for Start, up to 64 Latin letters, digits,_and-:https://mandarin.im/weather_bot?start=parissends/start paris. - Groups. People add a bot to their groups, if the bot allows it: turn that
on or off with
/setjoingroupsto @bots. In a group, a bot is a member like anybody: it can be given a role, restricted, banned or removed. - Channels. In a channel, only its owner, admins and editors post: make the bot an admin or an editor there for it to post.
- Leaving.
leaveConversationtakes the bot out of a group or a channel.
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
data: 1 to 64 bytes your bot is told of when somebody presses it, orurl: anhttporhttpsaddress the app opens once the person agrees.
{
"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.