@cherrydotfun/bots-sdk
v0.2.0
Published
Node SDK for the Cherry Bot API — typed bot client with DM, group mentions, inline keyboards, polling, webhooks, signature/transaction requests, and inline miniapp messages.
Readme
@cherrydotfun/bots-sdk
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).
Table of contents
- 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
Install
npm install @cherrydotfun/bots-sdk
# or
pnpm add @cherrydotfun/bots-sdk
# or
bun add @cherrydotfun/bots-sdkQuick start
import { 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 calledAuthentication & token
Every 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.
Configuration — BotConfig
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.
The Bot class
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).
Events
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));Modules
bot.dm — direct messages
// 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.
bot.messages — group messages & edits
// 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 callback queries
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: { … } },
});
});bot.auth — signature & transaction requests
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 — read the @handle
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.
bot.miniapp — miniapps & blinks
// 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 }bot.commands — the / command menu
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.
Inline keyboards
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).
Blinks (inline widgets)
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'.
Render height
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.
Transports
Exactly one transport at a time — polling or webhooks.
Long-polling
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());PollingConfig / OffsetStorage
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.
Webhooks
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);dispatchWebhook(rawBody, headers, opts)
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
}verifyWebhook(opts) → boolean
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.
Error handling
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.
Type reference
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.
BotConfig
See Configuration.
BotUser
interface BotUser {
walletAddress: string;
handle?: string;
displayName?: string;
avatarUrl?: string;
}MessageAttachment
interface MessageAttachment {
kind: 'image' | 'video' | 'audio' | 'file' | 'gif';
url: string;
mime?: string;
width?: number;
height?: number;
}BotMessageRef
interface BotMessageRef {
roomId: string;
messageId: string;
text?: string;
attachments?: MessageAttachment[];
createdAt: string; // ISO-8601
from: BotUser;
replyToMessageId?: string;
}InlineButton
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 } });ReplyMarkup
interface ReplyMarkup { inline_keyboard: InlineButton[][] }BlinkMessage
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;
}UpdateKind
type UpdateKind =
| 'message' | 'group_message' | 'mention'
| 'callback_query' | 'signature_response' | 'transaction_response'
| 'member_event' | 'assignment_event';BotUpdate
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;
}CallbackQuery
interface CallbackQuery {
id: string;
from: BotUser;
roomId: string;
messageId: string;
callbackData: string;
source: 'inline_keyboard' | 'blink_widget';
}SignatureResponse
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
}TransactionResponse
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;
}MemberEvent
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;
}AssignmentEvent
interface AssignmentEvent {
roomId: string;
kind: 'attached' | 'detached' | 'invite_accepted' | 'invite_rejected';
ownerWallet: string;
at: string;
detachedBy?: 'owner' | 'admin' | 'app';
}BotEventMap
Listener signatures for bot.on(...). See Events.
BotMeResponse
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
}SendDirectMessageRequest
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>;
}SendMessageRequest
interface SendMessageRequest {
content: string; // ≤ 4096 chars
replyToMessageId?: string;
metadata?: Record<string, unknown>;
}SendInteractiveMessageRequest
interface SendInteractiveMessageRequest extends SendMessageRequest {
reply_markup?: ReplyMarkup;
blink?: BlinkMessage;
}AnswerCallbackQueryRequest
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
}BotCommand
interface BotCommand {
command: string; // "/start" — /^\/[a-z0-9_]{1,32}$/
name?: string; // ≤ 32 chars
description: string; // ≤ 256 chars
params?: string; // "<amount> [token]" — ≤ 64 chars
}Endpoint reference
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? }.
Identity & config
| 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}$/.
Direct messages
| 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?.
Group messages & edits
| 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 }.
Callbacks
| 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.
Signature / transaction requests
| 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.
Miniapps & blinks
| 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 } |
Webhook & updates
| 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'] }), });
Scopes reference
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.
Example
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.
License
MIT
