Typed Node.js SDK for the Cherry Bot API — build bots for Cherry
with DMs, group mentions, inline keyboards, long-polling or webhooks,
signature/transaction requests, inline miniapp "blinks", and a Telegram-style
/ command menu.
- Runtime: Node.js ≥ 20 (uses the global
fetch; a polyfill can be injected). - Language: TypeScript (ships
.d.ts), works from plain JS too. - Transport: long-poll (
bot.start()) or HMAC-signed webhooks — mutually exclusive. - Auth: a single Bearer token
cherry_bot_<botId>_<secret>(the legacycha_<appId>_<secret>still works).
- Install
- Quick start
- Authentication & token
- Configuration —
BotConfig - The
Botclass - Events
- Modules
- Inline keyboards
- Blinks (inline widgets)
- Transports
- Error handling
- Type reference
- Endpoint reference
- Scopes reference
- Example
- License
npm install @cherrydotfun/bots-sdk
# or
pnpm add @cherrydotfun/bots-sdk
# or
bun add @cherrydotfun/bots-sdkimport { Bot, InlineKeyboard } from '@cherrydotfun/bots-sdk';
const bot = new Bot({
baseUrl: 'https://chat.cherry.fun',
token: process.env.CHERRY_BOT_TOKEN!, // cherry_bot_<botId>_<secret>
polling: { timeout: 25 },
});
// Who am I?
const me = await bot.me();
console.log(`Connected as bot ${me.botId ?? me.appId} wallet=${me.botWallet} scopes=${me.scopes.join(',')}`);
// Reply to DMs
bot.on('message', async (m) => {
await bot.dm.send({ toWallet: m.from.walletAddress, content: `You said: ${m.text}` });
});
// Reply when @-mentioned in a group
bot.on('mention', async (m) => {
await bot.messages.send(m.roomId, { content: `Hi <@${m.from.walletAddress}>!` });
});
// Inline keyboard + callback
bot.on('callback_query', async (q) => {
await bot.callbacks.answer(q.id, { text: `You picked ${q.callbackData}` });
});
await bot.start(); // begins the long-poll loop; resolves when bot.stop() is calledEvery request is authenticated with a Bearer token of the form:
cherry_bot_<botId>_<secret>
The token is minted in the Cherry admin panel (Apps → API Keys). The app must
have API access enabled and the relevant scopes granted (see
Scopes reference). The constructor validates the prefix and throws synchronously unless the token
starts with cherry_bot_ (issued by the developer portal) or the legacy cha_.
Scopes are read once per process via
bot.me(). If an admin grants a new scope while the bot is running, restart the bot so it re-fetches/me.
const bot = new Bot(config: BotConfig);| Field | Type | Default | Description |
|---|---|---|---|
baseUrl |
string |
— (required) | Cherry server base URL, e.g. https://chat.cherry.fun. |
token |
string |
— (required) | Bot token cherry_bot_<botId>_<secret> (legacy cha_<appId>_<secret> accepted). |
fetch |
typeof fetch |
globalThis.fetch |
Optional fetch polyfill (Node < 18 / custom agents). |
timeout |
number |
30000 |
Per-request timeout, ms. The poll loop overrides this for getUpdates. |
polling |
PollingConfig |
— | Enables long-poll transport. Mutually exclusive with webhooks. |
keyCustody |
'server' | 'self' |
'server' |
DM encryption custody. server (default): Cherry holds the bot key and encrypts/decrypts DMs server-side. 'self' is reserved / not yet implemented — the SDK performs no client-side crypto; use 'server'. |
The constructor throws Error if baseUrl/token are missing, the token has the
wrong prefix, or keyCustody is an invalid value.
class Bot {
// Modules
readonly dm: DmModule;
readonly messages: MessagesModule;
readonly callbacks: CallbacksModule;
readonly auth: AuthModule;
readonly handle: HandleModule;
readonly miniapp: MiniappModule;
readonly commands: CommandsModule;
constructor(config: BotConfig);
/** GET /api/v1/bots/me — identity, handle, scopes, keyCustody, webhookUrl, blinkOrigins. */
me(): Promise<BotMeResponse>;
/** Subscribe / unsubscribe to events. Returns `this` for chaining. */
on<K extends keyof BotEventMap>(event: K, handler: BotEventMap[K]): this;
off<K extends keyof BotEventMap>(event: K, handler: BotEventMap[K]): this;
/** Manually dispatch an update (used by webhook adapters after verification). */
dispatch(update: BotUpdate): Promise<void>;
/** Long-poll transport control. */
start(): Promise<void>; // resolves only when stop() is called
stop(): void; // idempotent
}
/** Type-narrow an update by kind. */
function isUpdateKind<K extends UpdateKind>(u: BotUpdate, kind: K): u is BotUpdate & { kind: K };Handlers are stored in a Set per event — registering the same function twice is a
no-op. A handler that throws is routed to the error event (never crashes the loop).
Subscribe with bot.on(kind, handler). Each incoming BotUpdate is
first delivered to the catch-all update listener, then to the kind-specific one.
| Event | Payload | Fires when |
|---|---|---|
message |
BotMessageRef |
A user sends the bot a DM. |
group_message |
BotMessageRef |
A message is posted in a group where the bot opted into read-all (botReadAll). |
mention |
BotMessageRef |
The bot is @-mentioned in a group it's a member of. |
callback_query |
CallbackQuery |
A user taps an inline-keyboard button or a blink widget action. |
signature_response |
SignatureResponse |
A user responds to a request_sign. |
transaction_response |
TransactionResponse |
A user responds to a request_tx. |
member_event |
MemberEvent |
A membership change in a room the bot is in (join/leave/kick/ban/role…). |
assignment_event |
AssignmentEvent |
The bot is attached/detached from a room, or an assignment invite is answered. |
update |
BotUpdate |
Every update (before the kind-specific handler). |
error |
unknown |
A transport error or an exception thrown inside a handler. |
bot.on('update', (u) => console.debug('update', u.kind, u.update_id));
bot.on('error', (err) => console.error('bot error', err));// Send a 1-to-1 message (server-custody: pass `content`).
bot.dm.send(payload: SendDirectMessageRequest): Promise<BotMessageRef>;
// Fetch DM history with one user (paginated, newest first).
bot.dm.getChat(opts: {
withWallet: string;
limit?: number; // page size
before?: string; // cursor (messageId)
}): Promise<{ messages: BotMessageRef[]; nextCursor?: string }>;await bot.dm.send({ toWallet: '5U6mn…DnWs', content: 'gm 🌅' });
const { messages, nextCursor } = await bot.dm.getChat({ withWallet: '5U6mn…DnWs', limit: 20 });Self-custody (
keyCustody: 'self') is not yet implemented — the SDK ships no client-side crypto and won't build theencryptedpayload for you. Usecontent(server-custody). Theencryptedfield exists on the wire type for forward-compat only.
// Plain text into a group room.
bot.messages.send(roomId: string, payload: SendMessageRequest): Promise<BotMessageRef>;
// Text + inline keyboard and/or inline blink.
bot.messages.sendInteractive(roomId: string, payload: SendInteractiveMessageRequest): Promise<BotMessageRef>;
// Edit a previously-sent message's text (optionally swap reply_markup).
bot.messages.editText(roomId, messageId, content, opts?: { replyMarkup?: ReplyMarkup | null }): Promise<BotMessageRef>;
// Replace (or clear with `null`) the inline keyboard.
bot.messages.editReplyMarkup(roomId, messageId, replyMarkup: ReplyMarkup | null): Promise<BotMessageRef>;
// Delete a bot-authored message.
bot.messages.delete(roomId, messageId): Promise<void>;import { InlineKeyboard } from '@cherrydotfun/bots-sdk';
const kb = new InlineKeyboard()
.row({ text: '👍', callback_data: 'yes' }, { text: '👎', callback_data: 'no' })
.build();
const msg = await bot.messages.sendInteractive(roomId, {
content: 'Do you like Cherry?',
reply_markup: kb,
});
await bot.messages.editText(roomId, msg.messageId, 'Thanks for voting!');bot.callbacks.answer(callbackId: string, payload?: AnswerCallbackQueryRequest): Promise<void>;Acknowledge an inline-keyboard / blink callback. You can piggyback an atomic message edit (text, reply_markup, or blink params) to avoid a second round-trip:
bot.on('callback_query', async (q) => {
await bot.callbacks.answer(q.id, {
text: 'Saved ✓', // toast
editReplyMarkup: null, // clear the buttons
// alert: true, // blocking dialog instead of toast
// editMessage: { content: '…' },
// updateBlink: { params: { … } },
});
});Convenience wrappers that send an interactive message containing exactly one
request_sign / request_tx button. Pair with the matching event.
bot.auth.requestSignature(opts: RequestSignatureOptions): Promise<BotMessageRef>;
bot.auth.requestTransaction(opts: RequestTransactionOptions): Promise<BotMessageRef>;await bot.auth.requestSignature({
toWallet: user, // or roomId for an in-group request
requestId: 'login-42',
message: 'I agree to the Cherry ToS',
prompt: 'Please confirm:',
buttonText: 'Sign in',
});
bot.on('signature_response', (r) => {
if (r.status === 'ok' && r.verified) {
console.log(`${r.from.walletAddress} signed request ${r.requestId}`);
}
});
await bot.auth.requestTransaction({
toWallet: user,
requestId: 'tip-1',
transaction: base64SerializedTx, // ≤ 1232 bytes
submit: true, // server submits via Helius and reports the signature
});
bot.on('transaction_response', (r) => {
if (r.status === 'ok') console.log('on-chain signature:', r.signature);
});interface RequestSignatureOptions {
roomId?: string; toWallet?: string; // one of the two — in-group vs DM
requestId: string;
message: string; // ≤ 1024 bytes utf-8
prompt?: string;
buttonText?: string; // default "Sign"
}
interface RequestTransactionOptions {
roomId?: string; toWallet?: string;
requestId: string;
transaction: string; // base64 serialized Solana tx, ≤ 1232 bytes
submit?: boolean; // server submits via Helius and reports the on-chain signature
expiresAt?: number;
prompt?: string;
buttonText?: string;
}bot.handle.get(): Promise<HandleInfo | null>; // null when no handle is reserved
// interface HandleInfo { handle: string; botWallet: string; reservedAt: string }Handles are globally unique ([a-z0-9_]{3,32}, must end in bot) and
admin-reserved — there is no self-serve setter. When a user types @handle in
a group the bot is a member of, the server emits a mention event. The current
value is also on (await bot.me()).handle.
// Attach the default miniapp used for inline blinks / launch.
bot.miniapp.setBlinkMiniApp(req: SetBlinkMiniAppRequest): Promise<BlinkMiniAppConfig>;
// Mint a one-time full-screen launch URL + token.
bot.miniapp.launch(req?: LaunchMiniAppRequest): Promise<LaunchMiniAppResponse>;await bot.miniapp.setBlinkMiniApp({ miniAppId: 'suk3…4S', defaultRoute: '/home' });
const { url, token, expiresAt } = await bot.miniapp.launch({ route: '/play', roomId });interface SetBlinkMiniAppRequest { miniAppId: string; defaultRoute?: string }
interface BlinkMiniAppConfig { miniAppId: string; version?: string; defaultRoute?: string }
interface LaunchMiniAppRequest { miniAppId?: string; route?: string; params?: Record<string, unknown>; roomId?: string }
interface LaunchMiniAppResponse { url: string; token: string; expiresAt: string }Publish a Telegram-style command list. Cherry clients suggest these when a user
types / in a DM with the bot (and /command@bothandle in groups).
bot.commands.set(commands: BotCommand[]): Promise<{ commands: BotCommand[] }>; // replace (not merge), max 100
bot.commands.get(): Promise<{ commands: BotCommand[] }>;await bot.commands.set([
{ command: '/start', name: 'Start', description: 'Begin' },
{ command: '/tip', name: 'Tip', description: 'Send a tip', params: '<amount> [token]' },
]);Command tokens must match
/^\/[a-z0-9_]{1,32}$/(no hyphens). Requires thebots:commands:managescope; gate the call onme.scopes.includes('bots:commands:manage')to avoid a 403.
reply_markup.inline_keyboard is a 2-D array of InlineButton.
Use the fluent InlineKeyboard builder:
import { InlineKeyboard } from '@cherrydotfun/bots-sdk';
const kb = new InlineKeyboard()
.row({ text: '👍', callback_data: 'up' }, { text: '👎', callback_data: 'down' })
.row({ text: 'Open site', url: 'https://cherry.fun' })
.row({ text: 'Sign in', request_sign: { requestId: 'r1', message: 'I agree' } })
.button({ text: 'Mini app', web_app: { route: '/home' } })
.build(); // → ReplyMarkupInlineKeyboard methods: .row(...buttons), .button(btn), .build(), .rowCount.
Button variants (exactly one action field per button):
| Variant | Action field | Effect on tap |
|---|---|---|
| Callback | callback_data: string (≤ 64 bytes) |
callback_query event |
| URL | url: string (https) |
opens URL |
| Web app | web_app: { route?, params? } |
opens miniapp |
| Request sign | request_sign: { requestId, message } |
signature_response event |
| Request tx | request_tx: { requestId, transaction, expiresAt?, submit? } |
transaction_response event |
Shared options on every button: once?, once_global?, expires_at? (see
InlineButton).
A blink is a widget mounted inside a message bubble. Two flavours via the
discriminated union BlinkMessage:
// 1) Miniapp blink (default) — embeds the bot's attached miniapp by route.
await bot.messages.sendInteractive(roomId, {
content: 'Weekly leaderboard:',
blink: { type: 'miniapp', route: '/leaderboard', height: 'medium', interactive: true },
});
// 2) URL blink — embeds an arbitrary bot-hosted page.
await bot.messages.sendInteractive(roomId, {
content: 'Play:',
blink: { type: 'url', url: 'https://yourbot.example/widget', height: 'tall' },
});
// 3) Pin an exact initial height so the card doesn't "jump" on load.
await bot.messages.sendInteractive(roomId, {
content: 'Result:',
blink: { type: 'miniapp', route: '/result', height: 'tall', initialHeight: 360 },
});- Miniapp blinks require an attached miniapp (
bot.miniapp.setBlinkMiniApp). - URL blinks require the origin to be in the workspace
blinkOriginsallowlist (admin-managed; read via(await bot.me()).blinkOrigins). Outside the list →BLINK_URL_NOT_ALLOWED. There is nosetBlinkOrigins— origins are workspace policy. - The hosted page must implement the Cherry Protocol v2 bridge
(
cherry:request/cherry:init/cherry:callback). - Widget actions arrive as
callback_querywithsource: 'blink_widget'.
height is the bucket (render ceiling), one of 'compact' | 'medium' | 'tall'
→ 96 | 220 | 420 px.
initialHeight (optional, CSS px) pins the height the card opens at, so it
renders at its real size on the first frame instead of jumping once the miniapp
reports its content height via host.resize. It must be a positive integer
≤ the bucket max for the chosen height (else BLINK_INITIAL_HEIGHT_INVALID).
When omitted, the card opens at the full bucket height. The miniapp can still
call host.resize afterwards — initialHeight only controls the first paint.
Exactly one transport at a time — polling or webhooks.
Pass polling in the config and call bot.start():
const bot = new Bot({
baseUrl, token,
polling: { timeout: 25, limit: 100, offsetStorage: myStorage },
});
await bot.start(); // loops on GET /api/v1/bots/getUpdates
process.on('SIGINT', () => bot.stop());interface PollingConfig {
timeout?: number; // long-poll wait, seconds. Default 25 (server caps at 50)
limit?: number; // max updates per request. Default 100
offsetStorage?: OffsetStorage;
}
interface OffsetStorage {
load(): Promise<number | undefined> | number | undefined;
save(updateId: number): Promise<void> | void;
}A trivial file-backed example:
import { readFileSync, writeFileSync } from 'node:fs';
const offsetStorage: OffsetStorage = {
load: () => { try { return Number(readFileSync('.offset', 'utf8')) || 0; } catch { return 0; } },
save: (id) => writeFileSync('.offset', String(id)),
};The loop swallows transient errors (timeouts, network blips, 404/408/502/503/504
during a server restart) and keeps retrying; everything else is surfaced to
bot.on('error'). The last-seen update_id is persisted via offsetStorage so a
restart resumes instead of replaying.
Construct the bot without polling, configure a webhook (the management
endpoints aren't wrapped by a module yet — call them directly, see
Webhook & updates), then verify + dispatch incoming POSTs:
import express from 'express';
import { Bot, dispatchWebhook } from '@cherrydotfun/bots-sdk';
const bot = new Bot({ baseUrl, token }); // no `polling` → webhook mode
const secret = process.env.CHERRY_WEBHOOK_SECRET!;
const app = express();
app.use(express.text({ type: '*/*' })); // raw body required for HMAC
app.post('/webhook', async (req, res) => {
const result = await dispatchWebhook(req.body as string, req.headers, {
secret,
onUpdate: (u) => bot.dispatch(u), // re-uses your bot.on(...) handlers
});
res.status(result.status).end(result.error ?? 'ok');
});
app.listen(3210);Verifies the HMAC signature, parses the payload (Cherry envelope or bare
BotUpdate), and invokes onUpdate. Returns { status: 200 | 400 | 401, error? }.
interface WebhookDispatcherOptions {
secret: string;
onUpdate: (u: BotUpdate) => void | Promise<void>;
toleranceSeconds?: number; // default 300
}Low-level signature check if you want to verify without dispatching.
interface VerifyWebhookOptions {
rawBody: string;
headers: Record<string, string | string[] | undefined>;
secret: string;
toleranceSeconds?: number; // default 300
}Signature scheme (same as the Apps webhook surface):
X-Cherry-Timestamp: <unix seconds>
X-Cherry-Signature: sha256=<hex> // HMAC-SHA256(secret, `${timestamp}.${rawBody}`)
X-Cherry-Delivery: <uuid>
body: { event: 'bot_update', deliveryId, timestamp, data: <BotUpdate> }
Replay protection: requests outside toleranceSeconds (default 300) are rejected.
Every method rejects with CherryBotsError, mirroring the server envelope:
import { CherryBotsError } from '@cherrydotfun/bots-sdk';
try {
await bot.commands.set([/* … */]);
} catch (err) {
if (err instanceof CherryBotsError) {
console.error(err.code, err.status, err.message, err.details);
if (err.isTransient()) { /* retry */ }
}
}class CherryBotsError extends Error {
code: string; // 'HTTP_ERROR', 'NETWORK_ERROR', … or the server's ERROR_CODE
status: number; // HTTP status (0 for network/timeout/abort)
details?: unknown;
isTransient(): boolean;
}| Code | status |
Meaning |
|---|---|---|
REQUEST_TIMEOUT |
0 |
No response within the request timeout (transient). |
NETWORK_ERROR |
0 |
fetch threw before a response (DNS/TCP/TLS — transient). |
REQUEST_ABORTED |
0 |
Caller aborted via signal. |
HTTP_ERROR |
4xx/5xx |
Server returned an error with no/unknown error code. |
| server code | 4xx/5xx |
The server's own ERROR_CODE (e.g. DUPLICATE_COMMAND, BOT_NOT_PROVISIONED, WEBHOOK_CONFIGURED). |
isTransient() is true for REQUEST_TIMEOUT, NETWORK_ERROR, and statuses
404, 408, 502, 503, 504 (treated as "server momentarily unavailable" so a poll
loop survives a reboot). A genuinely wrong baseUrl/path returns 404 forever —
inspect status directly in one-shot startup probes.
All types below are exported from the package root (export type * from './types').
Module-local option types (RequestSignatureOptions, HandleInfo,
SetBlinkMiniAppRequest, LaunchMiniAppResponse, …) are documented inline with
their module above.
See Configuration.
interface BotUser {
walletAddress: string;
handle?: string;
displayName?: string;
avatarUrl?: string;
}interface MessageAttachment {
kind: 'image' | 'video' | 'audio' | 'file' | 'gif';
url: string;
mime?: string;
width?: number;
height?: number;
}interface BotMessageRef {
roomId: string;
messageId: string;
text?: string;
attachments?: MessageAttachment[];
createdAt: string; // ISO-8601
from: BotUser;
replyToMessageId?: string;
}Union of the variants below; each extends InlineButtonBase.
interface InlineButtonBase {
text: string;
once?: boolean; // one-shot per user
once_global?: boolean; // one-shot for everyone
expires_at?: number; // unix seconds
}
type InlineButton =
| (InlineButtonBase & { callback_data: string }) // ≤ 64 bytes
| (InlineButtonBase & { url: string })
| (InlineButtonBase & { web_app: { route?: string; params?: Record<string, unknown> } })
| (InlineButtonBase & { request_sign: { requestId: string; message: string } })
| (InlineButtonBase & { request_tx: { requestId: string; transaction: string; expiresAt?: number; submit?: boolean } });interface ReplyMarkup { inline_keyboard: InlineButton[][] }type BlinkMessage = BlinkMiniApp | BlinkUrl;
interface BlinkMiniApp {
type?: 'miniapp'; // default
route: string; // /^/[a-zA-Z0-9_\-/.]{0,256}$/
params?: Record<string, unknown>; // ≤ 4 KB JSON, depth ≤ 8
height?: 'compact' | 'medium' | 'tall'; // bucket: 96 | 220 | 420 px
initialHeight?: number; // px the card opens at; ≤ bucket max
interactive?: boolean;
}
interface BlinkUrl {
type: 'url';
url: string; // origin must be in blinkOrigins
params?: Record<string, unknown>;
height?: 'compact' | 'medium' | 'tall';
initialHeight?: number; // px the card opens at; ≤ bucket max
interactive?: boolean;
}type UpdateKind =
| 'message' | 'group_message' | 'mention'
| 'callback_query' | 'signature_response' | 'transaction_response'
| 'member_event' | 'assignment_event';interface BotUpdate {
update_id: number; // per-bot, monotonically increasing
kind: UpdateKind;
at: string; // ISO-8601
message?: BotMessageRef;
callback_query?: CallbackQuery;
signature_response?: SignatureResponse;
transaction_response?: TransactionResponse;
member_event?: MemberEvent;
assignment_event?: AssignmentEvent;
}interface CallbackQuery {
id: string;
from: BotUser;
roomId: string;
messageId: string;
callbackData: string;
source: 'inline_keyboard' | 'blink_widget';
}interface SignatureResponse {
requestId: string;
from: BotUser;
signedMessage: string; // base64
signature: string; // base64 Ed25519
status: 'ok' | 'declined';
verified: boolean; // server-side Ed25519 verify vs from.walletAddress
}interface TransactionResponse {
requestId: string;
from: BotUser;
signedTransaction?: string; // base64 (present iff status='ok')
signature?: string; // on-chain signature once submitted/observed
status: 'ok' | 'declined' | 'failed';
errorCode?: string;
}interface MemberEvent {
roomId: string;
kind: 'joined' | 'left' | 'kicked' | 'banned' | 'unbanned' | 'role_changed' | 'muted' | 'unmuted';
member: BotUser;
actor?: BotUser;
role?: 'owner' | 'admin' | 'moderator' | 'member';
at: string;
}interface AssignmentEvent {
roomId: string;
kind: 'attached' | 'detached' | 'invite_accepted' | 'invite_rejected';
ownerWallet: string;
at: string;
detachedBy?: 'owner' | 'admin' | 'app';
}Listener signatures for bot.on(...). See Events.
interface BotMeResponse {
appId: string;
botWallet: string;
handle?: string;
displayName?: string;
avatarUrl?: string;
scopes: string[];
keyCustody: 'server' | 'self';
webhookUrl?: string;
blinkOrigins?: string[]; // read-only; workspace policy
}interface SendDirectMessageRequest {
toWallet: string;
content?: string; // use this (server-custody)
// Reserved for the not-yet-implemented self-custody flow. The SDK does not
// build or populate this — leave it unset.
encrypted?: { ciphertext: string; nonce: string; ephemeralPublicKey: string };
metadata?: Record<string, unknown>;
}interface SendMessageRequest {
content: string; // ≤ 4096 chars
replyToMessageId?: string;
metadata?: Record<string, unknown>;
}interface SendInteractiveMessageRequest extends SendMessageRequest {
reply_markup?: ReplyMarkup;
blink?: BlinkMessage;
}interface AnswerCallbackQueryRequest {
text?: string; // toast/alert text
alert?: boolean; // blocking dialog vs toast
editMessage?: { content: string }; // atomic text edit
editReplyMarkup?: ReplyMarkup | null; // atomic markup edit (null clears)
updateBlink?: { params: Record<string, unknown> }; // atomic blink re-render
}interface BotCommand {
command: string; // "/start" — /^\/[a-z0-9_]{1,32}$/
name?: string; // ≤ 32 chars
description: string; // ≤ 256 chars
params?: string; // "<amount> [token]" — ≤ 64 chars
}Base path: /api/v1/bots. Auth: Authorization: Bearer cherry_bot_<botId>_<secret>
(legacy cha_<appId>_<secret> accepted).
All bodies are JSON; all responses are JSON unless noted. Errors use the
CherryBotsError envelope { error, message, details? }.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
GET |
/me |
— | bot.me() |
BotMeResponse |
GET |
/getMyHandle |
— | bot.handle.get() |
HandleInfo | null |
POST |
/setMyCommands |
bots:commands:manage |
bot.commands.set(commands) |
{ commands: BotCommand[] } |
GET |
/getMyCommands |
— | bot.commands.get() |
{ commands: BotCommand[] } |
POST /setMyCommands — body { commands: BotCommand[] } (max 100, replace-not-merge;
duplicate tokens → DUPLICATE_COMMAND). Command tokens must match /^\/[a-z0-9_]{1,32}$/.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
POST |
/sendDirectMessage |
bots:dm:send |
bot.dm.send(req) |
BotMessageRef |
GET |
/getDirectChat |
bots:dm:read |
bot.dm.getChat(opts) |
{ messages: BotMessageRef[]; nextCursor? } |
POST /sendDirectMessage — body SendDirectMessageRequest.
Requires a provisioned bot wallet (else BOT_NOT_PROVISIONED); sending to self → INVALID_TARGET.
GET /getDirectChat — query withWallet, limit?, before?.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
POST |
/sendGroupMessage |
bots:groups:send |
bot.messages.send(roomId, req) |
BotMessageRef |
POST |
/sendInteractiveMessage |
bots:interactive |
bot.messages.sendInteractive(roomId, req) |
BotMessageRef |
POST |
/editMessageText |
messages:edit |
bot.messages.editText(...) |
BotMessageRef |
POST |
/editMessageReplyMarkup |
messages:edit |
bot.messages.editReplyMarkup(...) |
BotMessageRef |
POST |
/deleteGroupMessage |
bots:groups:moderate |
bot.messages.delete(roomId, messageId) |
{ success: true } |
Bodies carry { roomId, ... }. sendInteractiveMessage accepts reply_markup
and/or blink. Edits target { roomId, messageId }.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
POST |
/answerCallbackQuery |
bots:callback:answer |
bot.callbacks.answer(id, payload) |
204 No Content |
Body { id, ...AnswerCallbackQueryRequest }. Re-answering a one-shot callback → 409.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
POST |
/requestSignature |
bots:sign:request |
bot.auth.requestSignature(opts) |
BotMessageRef |
POST |
/requestTransaction |
bots:tx:request |
bot.auth.requestTransaction(opts) |
BotMessageRef |
The user's reply arrives as a signature_response / transaction_response update.
| Method | Path | Scope | SDK | Response |
|---|---|---|---|---|
POST |
/setBlinkMiniApp |
bots:miniapp:link |
bot.miniapp.setBlinkMiniApp(req) |
BlinkMiniAppConfig |
POST |
/launchMiniApp |
bots:miniapp:link |
bot.miniapp.launch(req) |
{ url, token, expiresAt } |
| Method | Path | Scope | SDK | Notes |
|---|---|---|---|---|
GET |
/getUpdates |
bots:updates:poll |
bot.start() (internal) |
Long-poll. Query offset, limit, timeout (s). Returns BotUpdate[]. 409 if a webhook is configured (WEBHOOK_CONFIGURED) or a concurrent poll exists (POLL_CONFLICT). |
POST |
/setWebhook |
bots:webhook:manage |
(raw fetch) | Body { url, secret, events: string[] }. Disables polling. |
POST |
/deleteWebhook |
bots:webhook:manage |
(raw fetch) | Re-enables polling. |
GET |
/getWebhookInfo |
— | (raw fetch) | Current webhook config + last delivery. |
setWebhook/deleteWebhook/getWebhookInfoaren't yet wrapped by a dedicated SDK module — call them directly with the Bearer token, e.g.:await fetch(`${baseUrl}/api/v1/bots/setWebhook`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ url, secret, events: ['bot_update'] }), });
Granted per-app in the admin panel (Apps → API Keys). Read your effective set via
(await bot.me()).scopes.
| Scope | Unlocks |
|---|---|
bots:dm:send |
bot.dm.send |
bots:dm:read |
bot.dm.getChat |
bots:groups:send |
bot.messages.send |
bots:interactive |
bot.messages.sendInteractive |
messages:edit |
bot.messages.editText / editReplyMarkup |
bots:groups:moderate |
bot.messages.delete |
bots:callback:answer |
bot.callbacks.answer |
bots:sign:request |
bot.auth.requestSignature |
bots:tx:request |
bot.auth.requestTransaction |
bots:miniapp:link |
bot.miniapp.setBlinkMiniApp / launch |
bots:commands:manage |
bot.commands.set |
bots:updates:poll |
getUpdates (long-poll) |
bots:webhook:manage |
setWebhook / deleteWebhook |
bots:handle:manage |
handle administration (admin-reserved; read-only for bots) |
No scope is required for /me, /getMyHandle, /getMyCommands, /getWebhookInfo.
A runnable showcase bot exercising every feature (DM commands, group mentions,
inline keyboards, polling/webhooks, sign/tx, miniapp + blinks, the / menu) lives
in example/ — see example/README.md.
MIT