API reference

DChat Bot API

The Bot API is a small HTTP interface that the dchat-bot program serves on your own machine. Your bot code, in any language, talks to it with plain JSON. This page is the full reference: every method, every type, every limit. New to bots? Start with the overview.

In one minute

Run dchat-bot run, then call http://127.0.0.1:8081/getMe with the header Authorization: Bearer <token>. Every answer is JSON, either {"ok":true,"result":...} or {"ok":false,"error_code":...,"description":"..."}. A bot only answers: it can write to a user after that user has written to it.

Recent changes

Bot API 1.4October 2026

Mini apps.

Bot API 1.3October 2026

Inline mode.

Bot API 1.2October 2026

Groups and channels.

  • A bot can be added to a group like a person. Texts it is allowed to see arrive as updates with the group as the chat, and reply_to_message when they answer another message. Privacy mode is on by default, see Bots in groups.
  • New methods setMyPrivacy, setMyJoinGroups and leaveChat. getMe now reports can_join_groups and can_read_all_group_messages. The profile gained group_privacy and join_groups.
  • sendMessage takes group:<id> and channel:<id> chat ids, and reply_to_message_id in groups. editMessageText and deleteMessage change or remove the bot's own messages.
  • A bot made an admin of a channel can post, edit and delete, see Bots in channels.
  • Send limits: 20 a minute for each group or channel and 30 a second over all chats, answered with 429 and retry_after, see Limits and errors.

Bot API 1.1October 2026

Buttons and keyboards.

Bot API 1.0October 2026

The first bots.

  • Bot accounts, marked by the bot registry. The dchat-bot program with init, run and set-profile.
  • The local HTTP interface with a bearer token on 127.0.0.1.
  • getMe, getUpdates and sendMessage for private chats. A bot answers; it never writes first.

Authorizing your bot

A DChat bot is an ordinary account whose username ends in bot. There is no central server that holds your bot. You run the gateway yourself, and three secrets keep it yours. Keep all three out of source control, screenshots and chat messages.

1. The recovery phrase

Made on your machine by dchat-bot init and never shown or sent anywhere. It is stored only in vault.json in the data directory, encrypted with ChaCha20-Poly1305 under a key derived from your passphrase (PBKDF2-HMAC-SHA256, 250 000 rounds, random salt). Whoever has vault.json and the passphrase is the bot.

2. The passphrase

You choose it. It unlocks the vault, and every command needs it in the DCHAT_BOT_PASSPHRASE environment variable. The gateway refuses to start without it.

3. The local API token

32 random bytes written as 64 hex characters. init keeps it in the token file and prints it once at the end. Your code sends it as Authorization: Bearer <token> on every request. DCHAT_BOT_TOKEN overrides the file.

How the interface is locked

  • By default the gateway listens on 127.0.0.1 only, so the token works only from the same machine. Listening on any other address needs --allow-remote, prints a warning, and leaves the token as the only lock over plain HTTP. Put it behind TLS or a private network.
  • The Host header must be 127.0.0.1:<port>, localhost:<port> or [::1]:<port>. That stops a web page that points its own name at your machine.
  • A POST must be Content-Type: application/json, bodies over 64 KB are refused, and the gateway sends no CORS headers and answers no preflight, so a page you visit cannot drive the bot.
  • The token is compared in constant time. The gateway never prints message text, the recovery phrase, the passphrase or the token (except the token once at the end of init).
If a secret leaks

Turn the bot off in My bots in the DChat app, then make a new bot. A username cannot move to new keys, so the old username stays with the old (now disabled) bot. Disabled bots stop having their buttons drawn in the apps, are treated as privacy mode on in groups, and lose their channel rights.

You can read what users send your bot

Messages are end-to-end encrypted between the user and the bot, and the bot is you: everything users send arrives on your machine as plain text. DChat cannot read it, you can. The apps tell users this before they first write to a bot. Handle it with the same care you would want for your own messages.

Making requests

The base address is http://127.0.0.1:8081, or whatever you pass to dchat-bot run --listen. A method is a path on it: http://127.0.0.1:8081/sendMessage. Method names match without regard to case.

Requests

  • getMe and getUpdates accept GET or POST. Every other method is POST only, and answers 405 to anything else.
  • A GET sends its parameters in the query string and carries no body. A GET with a body is a 400.
  • A POST sends a JSON object with Content-Type: application/json. Values in the body win over a query value of the same name.
  • Whole numbers may be sent as numbers or as decimal strings. Booleans may be true and false or the text true and false.
  • Every request needs the Authorization: Bearer <token> header. A token in the URL is not accepted.
Example
curl -s http://127.0.0.1:8081/getMe \
  -H "Authorization: Bearer $DCHAT_BOT_TOKEN"

curl -s http://127.0.0.1:8081/sendMessage \
  -H "Authorization: Bearer $DCHAT_BOT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"chat_id":"dc1...","text":"Hello"}'

Responses

Every answer is JSON with the HTTP status of the result. A success is {"ok":true,"result":...}. A refusal is {"ok":false,"error_code":<http status>,"description":"..."}, and a 429 adds parameters.retry_after.

FieldTypeDescription
okBooleantrue on success, false on a refusal.
resultAnyPresent when ok is true. What each method returns is listed under its heading.
error_codeIntegerPresent when ok is false. The same number as the HTTP status.
descriptionStringPresent when ok is false. A plain sentence that says what to change.
parametersObjectPresent on a 429 only: {"retry_after": <seconds>}.
Refusal
{ "ok": false, "error_code": 429, "description": "Too Many Requests: retry after 12",
  "parameters": { "retry_after": 12 } }

All error codes are listed under Limits and errors.

Getting updates

Your bot learns what users do by asking for updates. Call getUpdates in a loop (long polling). Webhooks do not exist yet. The gateway keeps each update until you confirm it, so nothing is lost while your code is stopped or restarting; messages sent while the gateway itself was stopped arrive when it starts again.

Update

One incoming event. Exactly one of the optional fields is present.

FieldTypeDescription
update_idIntegerThe update's id. Starts at 1 and only ever grows, across restarts too.
messageMessageOptional. An incoming text message, or the data a mini app sent back (message.web_app_data, with an empty text).
callback_queryCallbackQueryOptional. A tap on a callback button of a message this bot sent.
inline_queryInlineQueryOptional. A person typed @yourbot and a query in some chat. It names no chat. Answer it with answerInlineQuery.
A private message
{
  "update_id": 17,
  "message": {
    "message_id": "<id>",
    "from": { "wallet": "dc1...", "did": "did:dchat:...", "username": null, "name": "Alice" },
    "chat": { "id": "dc1...", "type": "private" },
    "date": 1760000000,
    "text": "/start"
  }
}
A button tap
{
  "update_id": 18,
  "callback_query": {
    "id": "00112233445566778899aabbccddeeff",
    "from": { "wallet": "dc1...", "did": "did:dchat:...", "username": "alice", "name": "Alice" },
    "message": { "message_id": "<the id sendMessage answered>", "chat": { "id": "dc1...", "type": "private" } },
    "data": "vote:yes"
  }
}
An inline query
{
  "update_id": 20,
  "inline_query": {
    "id": "11223344556677889900aabbccddeeff",
    "from": { "wallet": "dc1...", "did": "did:dchat:...", "username": "alice", "name": "Alice" },
    "query": "cats",
    "offset": ""
  }
}
Data from a mini app
{
  "k">"update_id": 19,
  "k">"message": {
    "k">"message_id": "<id>",
    "k">"from": { "k">"wallet": "dc1...", "k">"did": "did:dchat:alice", "k">"username": "alice", "k">"name": "Alice" },
    "k">"chat": { "k">"id": "dc1...", "k">"type": "private" },
    "k">"date": 1760600007,
    "k">"text": "",
    "k">"web_app_data": { "k">"data": "{\"pick\":2}", "k">"button_text": "Order" }
  }
}

Confirming updates

  • Pass offset set to the last update_id you handled plus one. That confirms and drops every update with a smaller id. An update you did not confirm is still there after a restart.
  • Updates arrive oldest first. timeout turns the call into a long poll that waits up to that many seconds for the first arrival.
  • The gateway holds at most 200 unconfirmed updates for one sender (that sender's oldest are dropped first, so one account flooding the bot cannot push out everyone else) and at most 10 000 in all.
  • A text longer than 4096 characters is cut to 4096, and its message carries "truncated": true.
  • Only texts and button taps become updates. Each incoming message is queued once, even if the relay delivers it again.

Group updates

A text in a group the bot is an active member of arrives as an update when its sender is an active member too. The chat is the group, chat.id is group:<id> and chat.title is its name. reply_to_message is present when the text answers another message.

A group message
{
  "update_id": 19,
  "message": {
    "message_id": "<id>",
    "from": { "wallet": "dc1...", "did": "did:dchat:...", "username": "alice", "name": "Alice" },
    "chat": { "id": "group:dc0123...", "type": "group", "title": "Team" },
    "date": 1760000000,
    "text": "/start",
    "reply_to_message": { "message_id": "<id>" }
  }
}
  • While privacy mode is on (the default) the senders only hand the bot some of a group's texts, see Privacy mode.
  • A text in a group never lets the bot write to that user in private. Only a private message does.
  • A callback on a group message names the group as callback_query.message.chat. It is passed on only when the message is a group message this bot sent and the user is a current member of that group.
  • Channels send no updates. A bot does not read a channel back.
When a callback is passed on

A callback_query reaches your code only when it names a message this gateway sent (it remembers the last 10 000) and comes from the user that message went to. Anything else is dropped. Answer it with answerCallbackQuery within 5 minutes.

Available types

All types are JSON objects. Field names are exactly as shown. Optional fields are left out when they have no value, unless a description says the field is always present and may be null.

User

A person or bot account. Used for from in updates.

FieldTypeDescription
walletStringThe account's wallet address, starting with dc1.
didStringThe account's DID. Empty when the message named none and nothing else knows it.
usernameString or nullThe username, or null when it is not known. The field is always present.
nameString or nullThe display name, or null when it is not known. The field is always present.

Chat

The conversation an update or a sent message belongs to.

FieldTypeDescription
idStringFor a private chat, the user's wallet (dc1...). For a group, group:<id>. For a channel, channel:<id>. This is the value to pass as chat_id.
typeStringprivate or group in updates. A send to a channel answers channel.
titleStringOptional. The group's name. Absent for a private chat.

Message

One incoming text.

FieldTypeDescription
message_idStringThe message's id. Pass it as message_id or reply_to_message_id where a method asks for one.
fromUserThe sender.
chatChatThe chat the message is in.
dateIntegerWhen the message was sent, in Unix seconds.
textStringThe text, at most 4096 characters.
truncatedBooleanOptional. true when the text was longer than 4096 characters and was cut. Left out otherwise.
reply_to_messageReplyRefOptional. The message this one answers, when it answers one.
via_botViaBotOptional. Present only on a text a person sent from an inline result of a bot. Such a text is never a command the person typed, even when it starts with /.
web_app_dataWebAppDataOptional. Present only on the update of a mini app's data. Such a message has an empty text.

ReplyRef

Names the message another one answers. It carries the id only, not the text.

FieldTypeDescription
message_idStringThe id of the message that was answered.

CallbackQuery

A tap on a callback button of a message this bot sent. Answer it once with answerCallbackQuery.

FieldTypeDescription
idStringThe callback's id: 32 hexadecimal characters. Pass it as callback_query_id.
fromUserThe user who tapped.
messageObjectThe message the button is on: an object with message_id (String, the id sendMessage answered) and chat (Chat, the user's chat or the group).
dataStringThe button's callback_data.

InlineQuery

What a person typed after @yourbot . It names no chat, no chat type and no other member. Answer it once with answerInlineQuery.

FieldTypeDescription
idStringThe query's id: 32 hexadecimal characters. Pass it as inline_query_id.
fromUserThe person who typed.
queryStringThe text after the bot's name, 0 to 256 characters. Empty when the person has typed only the name.
offsetStringThe paging offset: the next_offset you gave with the previous page, or an empty string for the first page.

The person need not have started the bot, and a query never counts as starting it: answering it earns no private messages. One person holds at most 32 pending queries here, and the oldest go first. A query can be answered for 30 seconds.

ViaBot

The inline bot a text was sent through. It is the label "via @yourbot" the apps draw on the message.

FieldTypeDescription
didStringThe inline bot's DID, did:dchat: and its username.
usernameStringThe bot's username, the part of the DID after did:dchat:.

Anyone can set this label on a text they send, so it proves nothing about what the text says or where it came from. The apps draw it only for a registered bot. Never run a command found in a text that has via_bot, and never trust its content. Such a text also never counts as a command, a mention or a reply for privacy mode.

InlineKeyboardMarkup

A keyboard of buttons that appears right under the message it is sent with. Send it as reply_markup. Works in private chats and in groups.

FieldTypeDescription
inline_keyboardArray of Array of InlineKeyboardButtonThe button rows. At most 12 rows, each with 1 to 8 buttons, and at most 100 buttons in all.
Example
{ "inline_keyboard": [
  [ { "text": "Thumbs up", "callback_data": "vote:up" },
    { "text": "Thumbs down", "callback_data": "vote:down" } ],
  [ { "text": "Open dchat.online", "url": "https://dchat.online" } ]
] }

InlineKeyboardButton

One button of an inline keyboard. Exactly one of callback_data, url, switch_inline_query, switch_inline_query_current_chat and web_app must be used.

FieldTypeDescription
textStringThe label. 1 to 64 characters, with no line breaks, control characters, or invisible and text direction characters (U+200C and U+200D are allowed).
callback_dataStringOptional. Tapping the button sends your bot a callback_query carrying this text. 1 to 64 bytes of UTF-8.
urlStringOptional. The link to open. The app asks the user before opening it. Must be https:// with a host and no user part (https://bank.example@evil.example is refused), or dchat://. At most 2048 characters, with no spaces, control or hidden characters and no backslash. Any other scheme is refused.
switch_inline_queryStringOptional. 0 to 256 characters, with no control or hidden characters. Accepted on the buttons of a message you send, and shown with a corner mark, but it does nothing when tapped: the chat picker it stands for is not built. It is refused in the reply_markup of an inline result. Use switch_inline_query_current_chat instead.
switch_inline_query_current_chatStringOptional. 0 to 256 characters on one line, with no control or hidden characters. Tapping it puts @yourbot and this text in the person's message field, in the chat they are in. It sends no query by itself: the app asks for results only after the person types. It works on the buttons of a message you send and in the reply_markup of an inline result.
web_appWebAppInfoOptional. Opens your mini app on that page. A mini app opened this way cannot send data back to the bot.

InlineQueryResultArticle

One result of an inline answer. Articles are the only kind there is: no photos, GIFs, stickers or files, and no thumbnails. A field not listed here is refused with 400.

FieldTypeDescription
typeStringMust be "article".
idStringYour own id for the result: 1 to 64 characters of A-Z, a-z, 0-9, _ and -.
titleStringThe first line of the row. 1 to 128 characters on one line, with no invisible, text direction or control characters.
descriptionStringOptional. Up to 3 lines under the title. 0 to 256 characters with at most 3 line breaks, and no invisible, text direction or other control characters.
input_message_contentInputTextMessageContentThe text that is sent into the chat when the person taps this result.
reply_markupInlineKeyboardMarkupOptional. Buttons under the sent text. An inline_keyboard of url buttons (https:// or dchat://, the same rules as InlineKeyboardButton) and switch_inline_query_current_chat buttons only. A callback_data, web_app or switch_inline_query button, a reply keyboard or remove_keyboard is refused. The usual markup limits apply.

The person sees the title, the description and the first line of the text before tapping. Nothing is sent until they tap.

InputTextMessageContent

The content of a result: a plain text message. It is the only kind DChat supports, so there is no location, contact, venue or invoice content, and no formatting parameter.

FieldTypeDescription
message_textStringThe text to send, 1 to 1,024 characters. Line breaks are allowed and no other control, invisible or text direction character. A field other than message_text is refused with 400.

The text is sent as the person's own message, from their own account, marked via @yourbot. Your bot is not told. The sent message shows no link preview, and its links stay behind the usual question before they open.

ReplyKeyboardMarkup

A keyboard that replaces the user's own keyboard. Tapping a button sends its text as an ordinary message. It stays until a later message of yours carries ReplyKeyboardRemove, or, with one_time_keyboard, until the user taps a button. Private chats only: a group answers 400 reply_keyboard_not_supported_in_groups.

FieldTypeDescription
keyboardArray of Array of KeyboardButtonThe button rows. Same limits as an inline keyboard: 12 rows, 1 to 8 buttons in a row, 100 in all.
resize_keyboardBooleanOptional. A display hint, passed on to the app.
one_time_keyboardBooleanOptional. Pass true to hide the keyboard after the user taps a button.
input_field_placeholderStringOptional. The hint shown in the input field while the keyboard is shown. Up to 64 characters, with no control or hidden characters.
is_persistentBooleanOptional. A display hint, passed on to the app.
Example
{ "keyboard": [[ { "text": "Red" }, { "text": "Green" } ], [ { "text": "Blue" } ]],
  "resize_keyboard": true, "one_time_keyboard": true, "input_field_placeholder": "Pick a colour" }

KeyboardButton

One button of a reply keyboard. A plain button sends its text as a message. A button with web_app opens your mini app instead, and that mini app can send one piece of data back, see WebAppData.

FieldTypeDescription
textStringThe label, and the text sent as a message when the user taps it. 1 to 64 characters, with no line breaks, control characters, or invisible and text direction characters.
web_appWebAppInfoOptional. Opens your mini app on that page. Same link rules as for an inline button.

ReplyKeyboardRemove

Send it as reply_markup to take a reply keyboard away. Private chats only.

FieldTypeDescription
remove_keyboardBooleanMust be true.

BotCommand

One command of the bot's menu. The apps show the commands in the menu and as / suggestions.

FieldTypeDescription
commandString1 to 32 characters of a-z, 0-9 and _. A leading / is dropped.
descriptionString1 to 256 characters.

WebAppInfo

The web_app field of a button. It names the page of your mini app the button opens.

FieldTypeDescription
urlStringAn https:// link of at most 1,024 characters on a plain host name, written in ASCII. An international host name is written as xn-- (punycode). No IP address, no port other than 443, no user part, no fragment (#), no spaces or backslash, and at least two name parts (app.example.com). The link must also be on the origin of the mini app you registered with setChatMenuButton. A button on any other origin is refused by the apps, even though the gateway sends it.

A bad link is refused with 400 and nothing is sent. One origin belongs to one bot: the registry refuses a link whose origin another bot already uses.

WebAppData

What a mini app sent back to its bot. It arrives in the web_app_data field of a Message with an empty text, only from a mini app opened with a reply keyboard button.

FieldTypeDescription
dataStringThe data the page sent: plain text, 1 to 4,096 bytes. Treat it as input from the user, not as something you can trust.
button_textStringThe label of the button the user tapped. The app sets it; the page cannot.

One piece of data is accepted each time the mini app is opened, and then the mini app closes. The sender in from is the account that sent it, checked by the app, never something the page says. Like a callback tap, it does not count as the user starting the bot: you can answer only a user who has already written to the bot.

Markup rules in one place
  • reply_markup is exactly one of an inline keyboard, a reply keyboard and {"remove_keyboard": true}.
  • The markup JSON is at most 8 KB. A row has 1 to 8 buttons.
  • Keys the gateway does not know are ignored. reply_markup may also be sent as a string that holds the same JSON.
  • The apps draw markup only on messages from an active registered bot. A broken limit is refused with 400 and a sentence saying which, and nothing is sent.

Available methods

These fourteen methods are the whole interface. Any other name answers 404 Not Found: no such method. Where a method says it counts against the send limits, see Limits and errors.

getMeGET or POST

A simple test of your connection and token. Takes no parameters. On success, returns an object with the fields below.

Result
FieldTypeDescription
walletStringThe bot's wallet address.
didStringThe bot's DID.
usernameStringThe bot's username, ending in bot.
nameStringThe bot's display name.
can_join_groupsBooleantrue when people can add the bot to groups (the profile's join_groups).
can_read_all_group_messagesBooleantrue when privacy mode is off (the profile's group_privacy is false).
supports_inline_queriesBooleantrue when the bot has an inline placeholder (setMyInlinePlaceholder), else false. Always present.
has_main_web_appBooleantrue when the bot registered a mini app with setChatMenuButton, else false. Always present.
web_app_urlStringOptional. The link of that mini app. Left out when the bot has none.

Both flags are read from the registry when the gateway starts, and changed by setMyJoinGroups and setMyPrivacy. supports_inline_queries follows setMyInlinePlaceholder. The mini app fields follow setChatMenuButton.

getUpdatesGET or POST

Use this method to receive incoming updates with long polling. Updates are oldest first. On success, returns an Array of Update objects, empty when nothing arrived before the timeout.

ParameterTypeRequiredDescription
offsetIntegerOptionalConfirms and drops every update with an id below this value. Pass the last update_id you handled plus one. A value above 2^53 is treated as 2^53.
timeoutIntegerOptionalSeconds to wait when nothing is pending, 0 to 50. Defaults to 0 (answer at once).
limitIntegerOptionalThe most updates to return, 1 to 100. Defaults to 100.

sendMessagePOST

Use this method to send a text message. A bot only answers: sending to a user who has never written to the bot is refused with 403 This user has not started the bot. On success, returns an object with message_id (String) and date (Integer, Unix seconds). For a group or a channel the object also holds chat with the id and type of the chat.

ParameterTypeRequiredDescription
chat_idStringYesThe target chat: the user's wallet (dc1 and lower-case bech32), group:<id> (dc and 32 lower-case hex digits) or channel:<id> (2 to 64 of a-z, 0-9, -, not starting with -). Anything else is a 400.
textStringYesThe text, 1 to 4096 characters. Blank text is a 400. It is sent as plain text.
reply_markupInlineKeyboardMarkup or ReplyKeyboardMarkup or ReplyKeyboardRemoveOptionalButtons or a keyboard for the message, checked against the markup limits before anything is sent. Groups take an inline keyboard only; channels take none.
reply_to_message_idStringOptionalGroups only: a message of that group, to make this message an answer to it. In a private chat it is ignored. For a channel it is a 400.

Where the message goes

  • A user. Only a wallet that has sent the bot at least one private message. A text a user sends in a group does not count.
  • A group. The bot must be an active member of that group in its own store, else 403 not_a_member. Inline keyboards only: a keyboard or remove_keyboard answers 400 reply_keyboard_not_supported_in_groups.
  • A channel. A plain text post, published as the bot with the rights the channel owner granted. reply_markup and reply_to_message_id answer 400 (markup_not_supported_in_channels for the markup). The answer's message_id is the post's sequence number. A bot with no right, or a disabled bot, gets a 403 from the middleware.

Every send counts against the limits: 20 a minute for each group and each channel, and 30 a second over all chats.

editMessageTextPOST

Use this method to change the text of a message the bot sent. It changes the text, not the buttons. On success, returns true.

ParameterTypeRequiredDescription
chat_idStringYesThe target chat: the user's wallet (dc1 and lower-case bech32), group:<id> (dc and 32 lower-case hex digits) or channel:<id> (2 to 64 of a-z, 0-9, -, not starting with -). Anything else is a 400.
message_idStringYesThe message to change. For a channel, the post's sequence number (a whole number, as a string or a number).
textStringYesThe new text, 1 to 4096 characters.

It acts only on messages this bot sent to that chat, which the gateway checks in its own record, else 403 Forbidden: the bot did not send that message in that chat. In a group the bot must still be a member (403 not_a_member). A channel post is held to the bot's rights by the middleware. A reply_markup is a 400. An edit counts against the send limits.

deleteMessagePOST

Use this method to delete a message the bot sent. The message is removed for everyone. On success, returns true.

ParameterTypeRequiredDescription
chat_idStringYesThe target chat: the user's wallet (dc1 and lower-case bech32), group:<id> (dc and 32 lower-case hex digits) or channel:<id> (2 to 64 of a-z, 0-9, -, not starting with -). Anything else is a 400.
message_idStringYesThe message to delete. For a channel, the post's sequence number.

The same checks as editMessageText apply: only the bot's own messages, membership in a group, channel rights from the middleware. A delete counts against the send limits.

answerCallbackQueryPOST

Use this method to answer a tap on an inline callback button. Each callback can be answered once, within 5 minutes of its arrival. Until you answer, the user's button shows a progress and the app gives up after about 15 seconds. On success, returns true.

ParameterTypeRequiredDescription
callback_query_idStringYesThe id of the CallbackQuery (32 hexadecimal characters).
textStringOptionalA notice for the user, 0 to 200 characters. It may hold line breaks and no other control or hidden character. Shown as a short strip at the top, or as an alert when show_alert is true.
show_alertBooleanOptionalPass true to show text as an alert dialog instead of a notice. Defaults to false.
urlStringOptionalAn https:// or dchat:// link the user is asked to open (the same rules as a button's url). It is shown only when there is no text, and never opened without asking.

An unknown id, one older than 5 minutes, or one answered already is refused with 400 query is too old, unknown, or already answered. The answer goes only to the user who tapped.

answerInlineQueryPOST

Use this method to answer an inline_query. Each query can be answered once, within 30 seconds of its arrival, and the answer goes only to the person who asked. On success, returns true.

ParameterTypeRequiredDescription
inline_query_idStringYesThe id of the InlineQuery (32 hexadecimal characters).
resultsArray of InlineQueryResultArticleYesThe results, 0 to 50 articles. A string that holds the same JSON is accepted too. The whole answer is at most 64 KB once framed: send fewer or shorter results and page the rest with next_offset.
cache_timeIntegerOptionalSeconds the person's app may reuse this answer for the same query and offset, 0 to 300. Defaults to 0.
next_offsetStringOptional0 to 64 characters of A-Z, a-z, 0-9, _ and -. When set, the app asks for the next page near the end of the list, with this value as the query's offset. Leave it out, or empty, when there is no next page.
switch_pm_textStringOptional1 to 64 characters on one line, with no invisible, text direction or control characters. Shows a row above the results that opens a chat with your bot.
switch_pm_parameterStringOptional0 to 64 characters of A-Z, a-z, 0-9, _ and -. It needs switch_pm_text. When the person taps the row, the app opens your chat and sends /start with this parameter.
An answer
{
  "inline_query_id": "11223344556677889900aabbccddeeff",
  "results": [{
    "type": "article", "id": "cat-1", "title": "A cat", "description": "Fluffy",
    "input_message_content": { "message_text": "meow" },
    "reply_markup": { "inline_keyboard": [[{ "text": "Site", "url": "https://example.com" }]] }
  }],
  "cache_time": 30, "next_offset": "p2",
  "switch_pm_text": "More in chat", "switch_pm_parameter": "from_inline"
}

Every limit is checked before anything is sent. A result that breaks one is a 400 that names it, such as results[2]: title must be ..., nothing is sent, and the query stays open so you can fix it and answer again. A failed send gives the query back too. An unknown id, one older than 30 seconds, or one answered already is refused with 400 query is too old and response timeout expired or query ID is invalid. An answer counts against the 30 a second send limit over all chats, see Limits and errors. Answering never lets the bot write to the person first. A GET is refused with 405.

setMyCommandsPOST

Use this method to change the bot's command menu, the commands the apps show in the menu and as / suggestions. It replaces the commands of the bot's registry profile and keeps its name, about and description. On success, returns true.

ParameterTypeRequiredDescription
commandsArray of BotCommandYesThe new list, up to 100 commands. An empty list clears the menu. A string that holds the same JSON is accepted too.

setMyInlinePlaceholderPOST

Use this method to make the bot an inline bot, and to set the grey hint the apps show after @yourbot while the query is still empty. It publishes the registry profile again as inline_placeholder and keeps the name, about, description, commands, group settings and mini app. setMyCommands, setMyPrivacy and setMyJoinGroups keep the placeholder too. On success, returns true.

ParameterTypeRequiredDescription
placeholderStringYesThe hint, 0 to 64 characters on one line, with no invisible, text direction or control characters. An empty string removes it, and the bot is then no longer an inline bot.

Without a placeholder the apps do not offer the bot inline. getMe reports the result as supports_inline_queries. profile.json may carry inline_placeholder as well.

setMyPrivacyPOST

Use this method to turn privacy mode on or off. It publishes the registry profile again with group_privacy set, and keeps the name, about, description and commands. On success, returns true. See Privacy mode.

ParameterTypeRequiredDescription
enabledBooleanYestrue (the default for every bot) turns privacy mode on: in groups the bot gets only commands, mentions and replies to it. false turns it off.

setMyJoinGroupsPOST

Use this method to allow or forbid adding the bot to groups. It publishes the registry profile again with join_groups set, and keeps the name, about, description and commands. On success, returns true.

ParameterTypeRequiredDescription
enabledBooleanYestrue lets everyone add the bot to groups. false sets the bot's own group add privacy to nobody and makes it leave every group it is in.

At start the gateway applies the registry's join_groups the same way. If the registry cannot be reached it uses what it applied last.

leaveChatPOST

Use this method to make the bot leave a group, the way a person does. On success, returns true.

ParameterTypeRequiredDescription
chat_idStringYesThe group, as group:<id>. Another kind of chat is a 400.

403 not_a_member when the bot is not in that group.

setChatMenuButtonPOST

Use this method to register the bot's mini app, or to remove it. It publishes the registry profile again and keeps everything else in it: name, about, description, commands, privacy and the other settings. setMyCommands, setMyPrivacy and setMyJoinGroups keep the mini app too. On success, returns true.

ParameterTypeRequiredDescription
urlStringOptionalThe mini app link. An https:// link of at most 1,024 characters on a plain host name, written in ASCII. An international host name is written as xn-- (punycode). No IP address, no port other than 443, no user part, no fragment (#), no spaces or backslash, and at least two name parts (app.example.com). Its origin (https://host) becomes the one origin of this bot: every mini app button you send must be on it. Leave it out, or send an empty string, to remove the mini app.
textStringOptionalThe label for the bot's Menu button, 1 to 64 characters on one line. With it, the Menu button opens the mini app instead of the command list. Without it, the link only fixes the origin for your buttons. Ignored when the mini app is removed.

The registry refuses a link whose origin another bot already uses. getMe then reports has_main_web_app and web_app_url.

validateWebAppDataPOST

Use this method from your own backend to check the launch data a mini app page gave you, and to learn which account opened it. It needs the bearer token like every method, so it can only be called on the machine that runs the gateway. On success, returns an object with the fields below.

Always validate on your backend

Never trust anything a mini app page tells you by itself, including initDataUnsafe, a user name or an id. A page is just a web page: anyone can open it in a browser and type any values. The page must send initData to your backend, and your backend must call validateWebAppData before it believes who the user is or does anything for them. Init data is good for 5 minutes by default (max_age can be raised to 24 hours at most), and each launch is accepted once. Then make your own short lived session for the user instead of keeping the init data.

ParameterTypeRequiredDescription
init_dataStringYesThe raw launch data, as the page read it from DChat.WebApp.initData.
max_ageIntegerOptionalHow old the data may be, in seconds: 1 to 86,400. Defaults to 300 (5 minutes). A string that holds a whole number is accepted too.
Result
FieldTypeDescription
userObjectThe account that opened the mini app: did, wallet and username, all strings. Never a name, picture, phone number or contact.
chat_typeStringprivate, group, or sender (opened from the Menu button or a link).
start_paramStringUp to 64 of A-Z a-z 0-9 _ -. Empty when none was given.
auth_dateIntegerWhen the app made the data, in Unix seconds.
A good answer
{ "k">"ok": true, "k">"result": {
  "k">"user": { "k">"did": "did:dchat:alice", "k">"wallet": "dc1...", "k">"username": "alice" },
  "k">"chat_type": "private", "k">"start_param": "", "k">"auth_date": 1760600000
} }

What is checked, and why it can be refused

The gateway checks in this order and stops at the first failure. A refusal is a 400 with a plain sentence unless the table says otherwise. The data is spent only when every check passed, so a refused call does not use it up.

Refusals
StatusReasonWhat to do
400The data does not decode, is not launch data of version 1, or is not in its exact form.Pass init_data exactly as the page gave it. Do not trim, decode or rebuild it.
400This launch data is for another bot.The user opened a different bot's mini app, or someone is replaying data from another bot.
403Forbidden: this bot has no mini appYou have not registered one. Call setChatMenuButton first.
400This launch data is for another origin than this bot's mini app.The page that produced the data is not on your registered origin.
400auth_date is in the future.More than 60 seconds ahead of the clock of the machine that runs the gateway. Check that clock.
400The launch data is too old.Older than max_age. The user has to open the mini app again.
400This launch data was already used.Each launch is accepted once. A second call with the same data is refused.
400The signature does not verify.The data was changed, or was not made by a DChat app.
400The user is not a registered DChat account.No account with that username exists on the chain.
400The signer is not this user.The key that signed is neither the user's own key nor a device the user's key vouches for.
400init_data is required or max_age must be 1 to 86400 secondsA missing or empty init_data, or a max_age out of range.
500The launch nonces could not be read or written.The gateway's store failed. Try again.

A GET to this method answers 405.

Not a login that lasts

A device that was removed from the account can still produce launch data that verifies for a while, because its certificate was signed before it was removed. Do not use launch data as a login that outlives the page session.

Command line: dchat-bot

dchat-bot is the program you run on your own machine. It holds the bot's keys, receives what users send, and serves the interface above. Every command needs DCHAT_BOT_PASSPHRASE.

Commands
CommandOptionsDescription
dchat-bot init--claim <code> (required), --data-dir, --yesCreates the bot. Makes the recovery phrase and keys locally, reads the claim you made in the app, checks that it was made for this code, is recent and is signed by the owner, shows you the owner and the bot's username and asks you to confirm (--yes skips the question), seals the recovery phrase in the data directory, registers the username on chain and binds the bot to its owner. Prints the bot's wallet and, once, the token. If it stops halfway, run the same command again: it resumes with the same keys.
dchat-bot run--data-dir, --listen <address>, --allow-remoteLogs in, connects, and serves the HTTP interface until you stop it. Defaults to ./dchat-bot-data and 127.0.0.1:8081. Messages sent while it was stopped arrive when it starts again.
dchat-bot set-profile--file <profile.json>, --data-dirPublishes the bot's profile: its name, about text, commands and group settings. The fields are below.

The claim code looks like K7QW-M2PX-9RTA. You get it in the app under My bots when you pick a name and a username (5 to 32 characters of a-z, 0-9 and _, ending in bot). It is valid for 15 minutes. The claim is made on your phone and the registry only ever sees its hash; init shows you the owner's username as the chain states it, never as the registry says it.

profile.json
FieldTypeDescription
nameStringOptional. The bot's name, 1 to 64 characters.
aboutStringOptional. A short line, up to 120 characters.
descriptionStringOptional. A longer description, up to 512 characters.
commandsArray of BotCommandOptional. Up to 100 commands.
inline_placeholderStringOptional. The hint after @yourbot while the query is empty, up to 64 characters. Setting it makes the bot an inline bot. See setMyInlinePlaceholder.
group_privacyBooleanOptional. Privacy mode. Defaults to true. Keeps the bot to commands, mentions and replies in groups.
join_groupsBooleanOptional. Whether people can add the bot to groups. Defaults to true.
profile.json
{
  "name": "Weather",
  "about": "Forecasts for any city",
  "description": "Send me a city name and I answer with the forecast.",
  "commands": [{ "command": "start", "description": "Begin" }],
  "group_privacy": true,
  "join_groups": true
}

The data directory holds vault.json (the encrypted recovery phrase and store key), token (the HTTP token), bot.json (wallet, username, owner) and core/ (the encrypted message store, which also holds the update queue). On Unix it is 0700 and every file in it 0600; on Windows keep it inside your own user profile.

Environment
VariableMeaning
DCHAT_BOT_PASSPHRASEUnlocks the vault. Required by every command.
DCHAT_BOT_TOKENOverrides the token kept in the token file.

Bots in groups

A group admin adds a bot like a person, in the apps by @username. The group's 100-member limit applies. A bot account accepts being added unless its profile says join_groups is false. Removing a bot rotates the group's sender key exactly as removing a person does. A bot never sees what was said before it joined: it has no key for it.

Privacy mode

Privacy mode is on by default. While it is on, the people who send to a group hand the bot only what is addressed to it. Turn it off with setMyPrivacy or group_privacy in the profile.

What reaches the bot
A text in the groupPrivacy mode on (default)Privacy mode off
A command: /cmd or /cmd@yourbotDeliveredDelivered
A text that names the bot: @yourbot or dchat:<did>DeliveredDelivered
A reply to one of the bot's own messagesDeliveredDelivered
Any other textNot deliveredDelivered
A command for another bot: /cmd@otherbotSkipped by every other botDelivered
Media, stickers, polls, pins, typing, read receipts, forwardsNever deliveredNot turned into updates (updates carry texts only)

The exact rules the sending apps follow while privacy mode is on:

  • Only a text message that is not forwarded and has no story reference can pass.
  • A command is the first word: /cmd for every bot, or /cmd@name where name equals the bot's username, ignoring ASCII case. A command is 1 to 32 of a-z, 0-9 and _.
  • A mention is @username as a whole word (ignoring ASCII case, with no letter, digit or _ on either side), or dchat:<bot did> followed by the end of the text or a character that is not a letter, digit or _.
  • A reply counts when it answers a message that the sender's own record shows was sent by the bot.
  • An edit, a delete or a reaction reaches the bot only for a message the bot itself received.
  • A bot that is an admin of the group gets all content, as other platforms do.
  • Group control messages (members added or removed, admin and info changes, key distribution and rotation) go to every member, bots included. They are not updates, but without them a bot could not read what it is meant to get.
Fail closed

A bot whose status is not known yet, whose answer from the registry is older than 24 hours, or that is not active is treated as privacy mode on. A fresh answer is used as it stands, and refreshed in the background once it is older than 10 minutes. Sending never waits for it.

Privacy mode is kept by the apps, not by cryptography

Every member of a group holds the group's keys, so a modified app could send a bot anything. Privacy mode is a rule that honest apps follow. Do not rely on it to keep a group away from a bot you do not trust. Against a stolen bot key, the owner disables the bot: apps stop drawing its buttons and treat it as privacy mode on.

Sending in a group

  • sendMessage with chat_id group:<id>. The bot must be an active member (403 not_a_member otherwise). reply_to_message_id makes the message an answer.
  • Inline keyboards only. A reply keyboard answers 400 reply_keyboard_not_supported_in_groups.
  • A member's tap on a button goes to the bot privately. The bot gets a callback_query naming the group, only when the tap comes from a current member, and answers it one to one.
  • editMessageText and deleteMessage act on the bot's own group messages.
  • leaveChat leaves the group.
  • A bot has no admin powers through this interface: it cannot add or remove members, promote anyone, rename the group or make invite links, even when it is a group admin.

Inline mode

Inline mode lets a person use your bot from any chat without opening it. They type @yourbot, a space and a query. The app lists your results above the message field, and the person taps one. That result's text is sent into the chat as the person's own message, marked "via @yourbot".

Turning it on

Call setMyInlinePlaceholder once, with the hint you want people to see, such as Search cats. That makes the bot an inline bot; getMe then reports supports_inline_queries as true. An empty placeholder turns it off again. The apps offer a bot inline only while it is an active registered bot with a placeholder.

How a query goes

  1. The person types @yourbot cats. The app waits 600 milliseconds after the last key press before it asks, so fast typing sends one query, not one for each letter. It sends at most 30 queries a minute to one bot.
  2. You receive an inline_query update from getUpdates. Its offset is empty for the first page.
  3. You call answerInlineQuery with up to 50 articles. Each query can be answered once, within 30 seconds, but the app stops waiting after 10 seconds, so answer fast.
  4. The person taps a result. The app sends its message_text to the chat they are in, as their own message with via_bot. Nothing is sent without that tap.

A button with switch_inline_query_current_chat fills the message field with @yourbot and its text. It fills it quietly: no query is sent until the person types.

Paging and caching

Set next_offset and the app asks for the next page, with that value as offset, when the person scrolls near the end of the list. Set cache_time and the app may reuse the answer for the same query and offset for that many seconds, instead of asking again. The app keeps at most 20 cached answers for each bot, and drops them when the person logs out or blocks or turns off the bot.

What your bot learns

Your bot learns the account of the person (DID, wallet and chat key, which every message from them carries), when they type, and everything they type after your bot's name. It never learns which chat they are in, the chat type, who else is in it, a location, or the person's profile. It never sees which result was picked or where it went, and there is no update for that.

A query and an answer are not messages. They make no chat, no row and no receipt on either side. The first time a person uses any inline bot, the app tells them that what they type after the bot's name is sent to that bot.

Messages sent through your bot

  • The message belongs to the person. It carries via_bot, and the apps draw "via @yourbot" on it only for a registered bot. Tapping the label puts @yourbot in the field, or opens your chat if the bot is not an inline bot.
  • A text with via_bot never counts as a command, a mention or a reply for privacy mode, and the bot is not sent the message because of the label. So a result such as /transfer@paybot 100 cannot reach another bot under the person's name.
  • The message shows no link preview, because the sender's app would otherwise fetch a link you chose. The link stays tappable behind the usual question.
  • Any person can type a label by hand, so it proves nothing. Never trust a message because it says "via".
Not in this version

Photo, GIF, sticker and file results, thumbnails, location, feedback about the chosen result, callback buttons on the sent message, the chat picker behind switch_inline_query, and inline mode in channels.

Mini apps

A mini app is your own web page, opened inside DChat from the bot's Menu button, an inline button or a reply keyboard button. The page learns, with proof, which DChat account opened it. No key ever enters the page, the page runs on your own https server, and DChat hosts nothing. The apps that open mini apps in this version are Android and the web app.

One origin for each bot

You register one link with setChatMenuButton. Every mini app button you send must be on that exact origin, or the apps refuse to open it. A page that navigates away to another origin leaves the mini app. Links follow the WebAppInfo rules.

What the page receives

The app signs launch data with the account's own key and puts it in the page's URL fragment, which no server ever sees: #dchatWebAppData=..., with dchatWebAppVersion, dchatWebAppPlatform and the theme colors beside it. The data carries the bot, the origin, the user's did and wallet, the chat type, the start parameter, the time and a one-time number. It carries no name, picture, phone number, contacts or chat id.

The page never decodes it. It hands the raw string to your backend, and your backend asks the gateway with validateWebAppData.

The page library

Include /js/dchat-web-app.js from dchat.online in your page. It finds the right way to talk to the app by itself and gives you window.DChat.WebApp:

  • Values and calls: initData, initDataUnsafe, canSendData, version, platform, themeParams, colorScheme, viewportHeight, ready(), expand(), close(), sendData(text), openLink(url), showPopup(params, callback), showAlert, showConfirm, setHeaderColor, setBackgroundColor, setBottomBarColor, enableClosingConfirmation(), disableClosingConfirmation(), onEvent(name, callback), offEvent(name, callback).
  • MainButton: setText, show, hide, enable, disable, showProgress, hideProgress, setParams, onClick, offClick.
  • BackButton: show, hide, onClick, offClick.
  • HapticFeedback: impactOccurred, notificationOccurred, selectionChanged.

initDataUnsafe is the same data, already read for you. It is named unsafe on purpose: only your backend may trust it, after validateWebAppData. sendData(text) works only for a mini app opened from a reply keyboard button; canSendData is true exactly then, so show your send button only when it is.

What a page can and cannot do

  • It can: read the launch data, take the app's colors and sizes, show a main button and a back button, ask before closing, open a native popup, alert or confirm, give haptic feedback, set its header, background and bottom bar colors, open an https link (only right after a tap, and the user is asked first), send one piece of data back, and close.
  • It cannot, in this version: use the camera, location, clipboard, downloads, file picking, QR scanning or payments. Every permission the page asks for is denied. It cannot draw over the app's own header, which always shows the bot name and the host. Any request the app does not support is answered as unsupported.
  • Messages between the page and the app are dropped when they are over 64 KB.

The first-open notice

The first time a user opens a bot's mini app, the app shows a notice that names what the page will receive, before the page opens. The user can clear a bot's mini app data from the app's settings.

Bots in channels

A bot posts to a channel exactly as a person admin does, signing with its own account key. The channel owner makes the bot an admin in the app, and the channel service enforces what it may do, so the bot cannot bypass it.

Channel rights
RightBots can hold itBot API
Post messagesYessendMessage with channel:<id>
Edit messagesYeseditMessageText
Delete messagesYesdeleteMessage
Pin messagesYesThe right can be granted. There is no pin method in the Bot API yet.
Add admins, change info, ban users, invite users, manage direct messagesNeverA grant that holds any of these is refused with 403 bot_right_refused. A leaked bot key must never add admins, rename the channel or read its direct messages.
  • Channel posts are plain text, 1 to 4096 characters. Buttons on a post are not available yet: reply_markup answers 400 markup_not_supported_in_channels.
  • The answer to a post carries the post's sequence number as message_id. Use it with edit and delete.
  • 20 posts, edits and deletes a minute for each channel. The middleware's own 64 KB body limit still applies.
  • A bot cannot post to a channel it is not an admin of, beyond the rights above, or after it has been disabled. A channel that lets anyone post does not cover a bot without its own grant.
  • A bot cannot create or own a channel. Channels send no updates, so a bot does not read a channel back.

Limits and errors

Send limits

Every send, edit and delete counts. Past a limit the answer is 429 with parameters.retry_after (whole seconds, at least 1) and nothing is sent.

Send limits
LimitValue
Each group and each channel20 sends, edits and deletes a minute
All chats together30 sends, edits and deletes a second
Private chatsOnly the 30 a second limit

These limits bind an honest owner. A stolen key can run its own client; against that the owner disables the bot.

Size limits

Limits
WhatLimit
Message text1 to 4096 characters
Request body64 KB
Markup JSON8 KB
Keyboard12 rows, 1 to 8 buttons a row, 100 buttons in all
Button text1 to 64 characters, no line breaks, control, invisible or text direction characters (U+200C and U+200D are allowed)
callback_data1 to 64 bytes
switch_inline_query0 to 256 characters
input_field_placeholderUp to 64 characters
Button or answer urlhttps:// with a host and no user part, or dchat://; up to 2048 characters; no spaces, control or hidden characters, no backslash
Callback answer text0 to 200 characters; line breaks are the only control character allowed
Callback answer window5 minutes, one answer
getUpdates timeout and limit0 to 50 seconds; 1 to 100 updates
CommandsUp to 100; 1 to 32 characters of a-z, 0-9, _; description 1 to 256
ProfileName 1 to 64, about up to 120, description up to 512 characters
Mini app linkhttps://, a plain ASCII host, up to 1,024 characters, no port other than 443, no user part, no fragment
Mini app data sent back1 to 4,096 bytes of text, once for each opening
validateWebAppData max_age1 to 86,400 seconds, 300 by default
Inline query text and answer windowQuery 0 to 256 characters; one answer, within 30 seconds; at most 32 pending queries for each person
Inline answer results0 to 50 articles; the whole answer at most 64 KB
Inline result id1 to 64 characters of A-Z, a-z, 0-9, _, -
Inline result title1 to 128 characters, one line
Inline result description0 to 256 characters, at most 3 line breaks
Inline result message_text1 to 1,024 characters
Inline cache_time0 to 300 seconds, 0 by default
Inline next_offset0 to 64 characters of A-Z, a-z, 0-9, _, -
switch_pm_text and switch_pm_parameterText 1 to 64 characters on one line; parameter 0 to 64 characters of A-Z, a-z, 0-9, _, -
switch_inline_query_current_chat0 to 256 characters
Inline placeholder0 to 64 characters on one line

Error codes

Errors
StatusDescriptionWhen
400Bad Request: ...A parameter is missing, the wrong type or over a limit, or a markup rule is broken. The sentence says which. Specific cases: markup_not_supported_in_channels, reply_keyboard_not_supported_in_groups, text is empty, query is too old, unknown, or already answered (a callback), query is too old and response timeout expired or query ID is invalid (an inline query).
401Unauthorized: send Authorization: Bearer <token>The token is missing or wrong.
403This user has not started the botYou tried to write to a user who has never written to the bot.
403Forbidden: not_a_member: the bot is not an active member of that groupA group method for a group the bot is not in.
403Forbidden: the bot did not send that message in that chatAn edit or delete of a message the bot did not send there.
403Forbidden: the Host header must be ...The Host header is not a loopback address with the gateway's port.
403From the channel serviceThe bot has no right in that channel, or has been disabled.
404Not Found: no such methodThe path is not one of the fourteen methods.
405Method Not AllowedA GET to a method that is POST only.
413Payload Too LargeThe body is over 64 KB.
415Unsupported Media Type: ...A POST without Content-Type: application/json.
429Too Many Requests: retry after <n>A send limit. Wait parameters.retry_after seconds.
500Internal Server Error: ...The update queue could not be written, or a message went out but could not be recorded (its buttons will not work).

Examples

A complete echo bot in Python, using the standard library only, ships with dchat-bot as examples/echo_bot.py. Send it /buttons to see inline buttons and a callback answer, and /keyboard and /hide for a reply keyboard. These are its two core pieces.

The call helper
import json, os, urllib.error, urllib.request

BASE = os.environ.get("DCHAT_BOT_URL", "http://127.0.0.1:8081")
TOKEN = os.environ["DCHAT_BOT_TOKEN"]

def call(method, **params):
    request = urllib.request.Request(
        f"{BASE}/{method}",
        data=json.dumps(params).encode("utf-8"),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=60) as response:
            answer = json.load(response)
    except urllib.error.HTTPError as e:
        answer = json.load(e)
    if not answer.get("ok"):
        raise RuntimeError(f"{method}: {answer.get('error_code')} {answer.get('description')}")
    return answer["result"]
The update loop
offset = 0
while True:
    updates = call("getUpdates", offset=offset, timeout=50)
    for update in updates:
        offset = update["update_id"] + 1
        if "callback_query" in update:
            query = update["callback_query"]
            call("answerCallbackQuery", callback_query_id=query["id"], text="Got it.")
        elif "message" in update:
            message = update["message"]
            call("sendMessage", chat_id=message["chat"]["id"], text=message["text"])

Note that chat_id is simply the chat.id of the update, which is the user's wallet in a private chat and group:<id> in a group. Wrap each update in a try block in a real bot, so one refused call does not stop the loop.

Buttons, sent with curl
curl -s http://127.0.0.1:8081/sendMessage \
  -H "Authorization: Bearer $DCHAT_BOT_TOKEN" -H "Content-Type: application/json" \
  -d '{"chat_id":"dc1...","text":"Do you like buttons?","reply_markup":{"inline_keyboard":[[{"text":"Yes","callback_data":"vote:yes"},{"text":"No","callback_data":"vote:no"}]]}}'

Coming from other bot platforms

Method names, field names and the answer envelope match the common Bot API on purpose, so code you already know ports with small changes. These are the differences that matter.

What is different
TopicOn DChat
Chat idsStrings, not numbers: a wallet (dc1...), group:<id> or channel:<id>. Message ids are strings too; a channel post's id is its sequence number.
Getting updatesgetUpdates long polling only. There are no webhooks yet.
Where the bot runsOn your machine. The gateway runs there, DChat hosts nothing and cannot read bot chats. There is no hosted Bot API server to point at.
The tokenMade on your machine by init, kept in a local file, sent only in the Authorization: Bearer header. It never goes in the URL and only works against your own gateway.
Writing firstA bot only answers. It can write to a user after that user has written to it in private. A text in a group does not count.
TextPlain text. The interface has no formatting parameter.
KeyboardsInline keyboards work in private chats and groups. Reply keyboards work in private chats only. Channel posts take no markup.
MethodsOnly the fourteen listed above. Any other name answers 404.
Inline modeResults are text articles only, with no media and no thumbnails. Your bot is never told which result was picked or which chat it went to. See Inline mode.
Not available yet
  • Inline mode extras. Media results, thumbnails, location, feedback about the chosen result, the chat picker behind switch_inline_query (accepted and shown, and does nothing), and inline mode in channels.
  • Payments, and mini app features that need the camera, location, clipboard or downloads.
  • Webhooks, media methods, and buttons on channel posts.