@cherrydotfun/apps-sdk
v0.4.0
Published
Node SDK for the Cherry messaging Third-party Apps API — typed client for groups, members, messages, plus webhook verification helpers.
Maintainers
Readme
@cherrydotfun/apps-sdk
Node.js SDK for the Cherry messaging Third-party Apps API.
Provides a typed client for managing groups, members, and messages in your app-managed Cherry rooms, plus utilities for verifying and parsing incoming webhooks.
- Runtime: Node.js ≥ 20 (uses global
fetchandcrypto). - No runtime dependencies.
- ESM + CJS dual build, full TypeScript typings.
Table of Contents
- Install
- Quick start
- Authentication
- Client
- Groups
- Members
- Messages
- Room owner action locks
- Webhooks
- Types reference
- Error handling
- Changelog
- License
Install
npm install @cherrydotfun/apps-sdk
yarn add @cherrydotfun/apps-sdk
pnpm add @cherrydotfun/apps-sdk
bun add @cherrydotfun/apps-sdkRequirements: Node.js ≥ 20. No runtime dependencies.
Quick start
import {
CherryAppsClient,
verifyWebhook,
parseWebhook,
CherryAppsError,
} from '@cherrydotfun/apps-sdk';
const cherry = new CherryAppsClient({
baseUrl: 'https://chat.cherry.fun',
appKey: process.env.CHERRY_APP_KEY!,
});
// Identity check
const me = await cherry.me();
console.log('App:', me.appId, 'Bot wallet:', me.botWallet);
// Create a group
const { roomId } = await cherry.groups.create({
ownerWallet: 'OwnerSolanaWallet1111111111111111',
title: 'Dragon Slayers',
description: 'Elite PvP clan',
initialMembers: ['Member1111…', 'Member2222…'],
});
// Send a bot message
await cherry.messages.send(roomId, { content: 'Welcome to the clan!' });Authentication
Every request to the Cherry API is authenticated with an app key — a single opaque Bearer token of the form:
cha_<appId>_<secret>The SDK reads it from config.appKey and sets the Authorization header
automatically. Issue / rotate keys through Cherry Admin. See
admin runbook.
Webhook secrets are separate from the app key (32-byte HMAC secret).
Client
new CherryAppsClient(config)
Construct a client. The instance is stateless — safe to share across requests.
const cherry = new CherryAppsClient({
baseUrl: 'https://chat.cherry.fun',
appKey: 'cha_<appId>_<secret>',
fetch: globalThis.fetch, // optional
timeout: 30_000, // optional, ms
});CherryAppsClientConfig
| Field | Type | Required | Default | Notes |
| ---------- | --------------- | -------- | ---------------- | ----------------------------------------------- |
| baseUrl | string | ✔ | — | Cherry server base URL, no trailing slash |
| appKey | string | ✔ | — | cha_<appId>_<secret> Bearer token |
| fetch | typeof fetch | ✘ | globalThis.fetch | Inject a custom fetch (tests, polyfills) |
| timeout | number | ✘ | 30000 | Request timeout in milliseconds (via AbortController) |
Instance properties
| Property | Type | Description |
| ------------------- | ---------------- | -------------------------------------------- |
| cherry.groups | GroupsModule | Group management |
| cherry.members | MembersModule | Member management |
| cherry.messages | MessagesModule | Message management |
| cherry.webhooks | WebhooksHelpers| { verify, parse } re-exports |
WebhooksHelpers
interface WebhooksHelpers {
verify: typeof verifyWebhook;
parse: typeof parseWebhook;
}client.me()
Returns the authenticated app's identity, scopes, and rate-limit info.
Signature
me(): Promise<AppMeResponse>Scope: none required.
Response — AppMeResponse:
| Field | Type | Notes |
| ---------------- | ------------------------------------------ | -------------------------------------------- |
| appId | string | Stable UUID of your app |
| scopes | string[] | Granted scopes (e.g. ['groups:create', 'messages:send']) |
| botWallet | string | Solana wallet that signs / appears on app-bot messages |
| botDisplayName | string \| undefined | Bot display name (admin-configurable) |
| rateLimits | Record<string, number> \| undefined | { perMinute, perDay } quotas |
client.getPublicAppInfo(appId)
Fetch unauthenticated public-safe metadata about any app.
Signature
getPublicAppInfo(appId: string): Promise<AppPublicInfo>Scope: none (no appKey required for this endpoint).
Response — AppPublicInfo:
| Field | Type | Notes |
| ---------------- | ------------------- | -------------------------------------- |
| name | string | Human-readable app name |
| botDisplayName | string \| null | Bot display name (may be null) |
| botAvatarUrl | string \| null | Bot avatar URL (may be null) |
| ownerWallet | string \| null | App owner's wallet (may be null) |
| botWallet | string \| null | Bot wallet (may be null if not yet provisioned) |
Groups
Namespace: cherry.groups. CRUD for app-managed rooms.
groups.create(input)
Create a new group. ownerWallet becomes an active member with role: 'owner'.
Signature
create(input: CreateGroupInput): Promise<CreateGroupResponse>Scope: groups:create.
CreateGroupInput:
| Field | Type | Required | Notes |
| ---------------------- | --------------------------------- | -------- | ------------------------------------------- |
| ownerWallet | string | ✔ | Solana base58 wallet, will become owner |
| title | string | ✔ | Group name |
| description | string \| undefined | ✘ | Optional bio |
| avatarUrl | string \| undefined | ✘ | Optional avatar URL |
| settings | Record<string, unknown> | ✘ | Free-form settings object |
| gatingRule | Record<string, unknown> | ✘ | Token-gating rule (see Cherry docs) |
| initialMembers | string[] | ✘ | Wallets auto-invited and auto-accepted |
| allowOwnerDetach | boolean | ✘ | Owner can disconnect the app. Default false for app-created rooms. See locks |
| allowOwnerDelete | boolean | ✘ | Owner can delete the group. Default false |
| allowOwnerTransfer | boolean | ✘ | Owner can transfer ownership. Default false |
Response — CreateGroupResponse:
| Field | Type | Notes |
| -------- | --------- | -------------------------------- |
| roomId | string | ID of the newly created room |
Errors:
| HTTP | Code | When |
| ---- | ---------------------------- | ------------------------------------------ |
| 400 | INVALID_INPUT | Missing/invalid wallet or title |
| 403 | MISSING_SCOPE | Token lacks groups:create |
| 429 | RATE_LIMITED | App over rateLimits.perMinute/perDay |
Example
const { roomId } = await cherry.groups.create({
ownerWallet: 'ABC1234567890…',
title: 'Dragon Slayers',
description: 'Elite PvP clan',
initialMembers: ['Member1…', 'Member2…'],
allowOwnerDetach: false,
allowOwnerDelete: false,
allowOwnerTransfer: false,
});groups.list(query?)
List groups managed by this app, newest first. Cursor-based pagination.
Signature
list(query?: ListGroupsQuery): Promise<ListGroupsResponse>Scope: groups:manage.
ListGroupsQuery:
| Field | Type | Required | Notes |
| -------- | --------- | -------- | --------------------------------------------------------------------- |
| cursor | string | ✘ | Opaque cursor from a previous response's nextCursor |
| limit | number | ✘ | Page size (1–100, default 50) |
Response — ListGroupsResponse:
| Field | Type | Notes |
| ------------ | --------------------- | ---------------------------------------------------- |
| rooms | AppRoom[] | Page of rooms (see AppRoom) |
| nextCursor | string \| undefined | Pass back as cursor to fetch the next page |
groups.get(roomId)
Get a single app-managed group.
Signature
get(roomId: string): Promise<AppRoom>Scope: groups:manage.
Errors:
| HTTP | Code | When |
| ---- | ---------------------------- | ------------------------------------------ |
| 403 | ROOM_NOT_MANAGED_BY_APP | Room exists but room.appId !== self.appId |
| 404 | ROOM_NOT_FOUND | No such room |
groups.update(roomId, patch)
Update group metadata. Empty patches are rejected (400).
Signature
update(roomId: string, patch: UpdateGroupInput): Promise<AppRoom>Scope: groups:manage.
UpdateGroupInput (all fields optional; at least one required):
| Field | Type | Notes |
| ---------------------- | --------------------------------- | ------------------------------------------- |
| title | string | Rename |
| description | string | Replace description |
| avatarUrl | string | New avatar URL |
| settings | Record<string, unknown> | Replace settings object |
| allowOwnerDetach | boolean | Toggle detach lock |
| allowOwnerDelete | boolean | Toggle delete lock |
| allowOwnerTransfer | boolean | Toggle transfer lock |
Returns: the updated AppRoom.
groups.delete(roomId)
Delete an app-created group (appCreated: true). Cannot delete an
assigned room (appCreated: false) — those must be detached by the owner or
an admin.
Signature
delete(roomId: string): Promise<void>Scope: groups:manage.
Errors:
| HTTP | Code | When |
| ---- | ------------------------------- | -------------------------------------------- |
| 403 | CANNOT_DELETE_ASSIGNED_ROOM | Room was obtained via assignment, not created |
| 403 | ROOM_NOT_MANAGED_BY_APP | Cross-app isolation |
Members
Namespace: cherry.members. Manage membership of app-managed groups.
members.invite(roomId, input)
Bulk-invite wallets. With autoAccept: true, invited members become active
immediately; otherwise they receive a pending invite they must accept in Cherry.
Signature
invite(roomId: string, input: InviteMembersInput): Promise<InviteMembersResponse>Scope: members:invite.
InviteMembersInput:
| Field | Type | Required | Notes |
| ------------- | ----------- | -------- | ---------------------------------------------------- |
| wallets | string[] | ✔ | List of Solana wallets to invite |
| autoAccept | boolean | ✘ | When true, also accept on behalf of each invitee |
Response — InviteMembersResponse:
| Field | Type | Notes |
| ---------- | ----------- | ---------------------------------------------------------------------- |
| invited | string[] | Wallets that received a fresh invite (or re-invite of a kicked member) |
| skipped | string[] | Wallets that were already active / pending / banned |
| accepted | string[] | Wallets activated immediately (only when autoAccept: true) |
members.kick(roomId, wallet)
Remove a member. They can be re-invited later (unlike ban).
Signature
kick(roomId: string, wallet: string): Promise<void>Scope: members:moderate.
Errors:
| HTTP | Code | When |
| ---- | -------------------------- | ------------------------------------- |
| 403 | CANNOT_MODERATE_OWNER | wallet === room.owner |
| 404 | MEMBER_NOT_FOUND | Wallet is not in the room |
members.ban(roomId, wallet, opts?)
Ban a member. Banned wallets cannot be re-invited until unbanned. Optionally delete all their existing messages.
Signature
ban(roomId: string, wallet: string, opts?: BanMemberInput): Promise<void>Scope: members:moderate.
BanMemberInput:
| Field | Type | Required | Notes |
| ----------------- | ---------- | -------- | ---------------------------------------------------- |
| deleteMessages | boolean | ✘ | If true, move all banned-user messages to deleted_messages |
members.unban(roomId, wallet)
Lift the ban. The user does not rejoin automatically — call
members.invite(...) afterwards if you want them back.
Signature
unban(roomId: string, wallet: string): Promise<void>Scope: members:moderate.
members.mute(roomId, wallet, opts?)
Shadow-mute a member. Their messages persist in their own client view but are not delivered to others. Optionally set an expiry timestamp.
Signature
mute(roomId: string, wallet: string, opts?: MuteMemberInput): Promise<void>Scope: members:moderate.
MuteMemberInput:
| Field | Type | Required | Notes |
| ---------- | ---------- | -------- | ------------------------------------------------------------- |
| untilTs | string | ✘ | ISO 8601 expiry. Omit for indefinite mute. |
members.unmute(roomId, wallet)
Lift the mute.
unmute(roomId: string, wallet: string): Promise<void>Scope: members:moderate.
members.setRole(roomId, wallet, role)
Set a member's role. The 'owner' role is not assignable through this
API — it can only be reached by the room owner via Cherry UI (and is
gated by allowOwnerTransfer for app-managed rooms).
Signature
setRole(
roomId: string,
wallet: string,
role: AppMemberRole, // 'admin' | 'moderator' | 'member'
): Promise<void>Scope: members:moderate.
Errors:
| HTTP | Code | When |
| ---- | -------------------------- | ------------------------------------- |
| 400 | INVALID_ROLE | role === 'owner' (rejected) |
| 403 | CANNOT_MODERATE_OWNER | Target is the room owner |
Messages
Namespace: cherry.messages.
messages.send(roomId, input)
Send a message as the app bot. The server-derived sender is always
botWallet from cherry.me() — the senderId in any client-supplied
payload is ignored (impersonation prevention).
Signature
send(roomId: string, input: SendMessageInput): Promise<AppMessage>Scope: messages:send.
SendMessageInput:
| Field | Type | Required | Notes |
| -------------- | --------------------------------- | -------- | ---------------------------------------------------------------------- |
| content | string | ✔ | Message body |
| messageType | string | ✘ | Free-form discriminator (e.g. 'text', 'announcement') |
| metadata | Record<string, unknown> | ✘ | Free-form payload. Server merges in senderType: 'app_bot', appId, appBotDisplayName, appBotAvatarUrl |
| attachments | unknown[] | ✘ | Reserved for future attachment support |
Returns the persisted AppMessage (with server-assigned id, createdAt, and merged metadata).
messages.delete(roomId, messageId)
Delete a message. The server moves it to rooms/{roomId}/deleted_messages/.
delete(roomId: string, messageId: string): Promise<void>Scope: messages:delete.
messages.list(roomId, query?)
List recent messages, newest first.
Signature
list(roomId: string, query?: ListMessagesQuery): Promise<ListMessagesResponse>Scope: messages:read.
ListMessagesQuery:
| Field | Type | Required | Notes |
| -------- | --------- | -------- | ---------------------------------------------------- |
| before | string | ✘ | Return messages older than this message ID (cursor) |
| limit | number | ✘ | Page size (1–100, default 50) |
Response — ListMessagesResponse:
| Field | Type | Notes |
| ------------ | --------------- | ---------------------------------------------------- |
| messages | AppMessage[] | Page of messages (newest first) |
| nextCursor | string \| undefined | Pass back as before to fetch the next page |
Room owner action locks
For groups your app creates (appCreated: true), you can lock the three
owner-only Cherry actions that are otherwise outside the app's veto:
| Field on the room | Locks the action | Cherry 403 code when blocked |
| ----------------------- | ------------------------------------------------- | ------------------------------------ |
| allowOwnerDetach | POST /api/v1/apps/:appId/disconnect-room (owner Cherry call) | DETACH_DISABLED_BY_APP |
| allowOwnerDelete | POST /room/group-delete (owner Cherry UI) | DELETE_DISABLED_BY_APP |
| allowOwnerTransfer | POST /room/group-set-role with role='owner' | TRANSFER_OWNER_DISABLED_BY_APP |
- Defaults: all three are
false(locked) forappCreated:truerooms. - Scope: only enforced for rooms your app created. Assigned rooms
(
appCreated:false, obtained via owner-accepted assignment invite) ignore the fields — the owner always retains full control. - Setting: pass at
groups.create()time or at any latergroups.update(). - Escape valve: Cherry SUPER_ADMIN can force-detach any room, which
clears
appId,appCreated, and all three flags, returning full control to the owner. The webhook fires withdetachedBy: 'admin'.
// Lock everything at creation
await cherry.groups.create({
ownerWallet, title: 'Locked Clan',
allowOwnerDetach: false,
allowOwnerDelete: false,
allowOwnerTransfer: false,
});
// Later, grant detach
await cherry.groups.update(roomId, { allowOwnerDetach: true });Webhooks
Cherry POSTs webhooks to the URL you configured in admin. Verify the signature before parsing or acting on the payload.
Webhook headers
| Header | Type | Notes |
| --------------------- | --------- | ------------------------------------------------------------------------------------------ |
| X-Cherry-Event | string | Event type, e.g. 'message.created'. Same as payload.event. |
| X-Cherry-Delivery | string | UUID identifying this delivery attempt. Use for idempotency / dedup. |
| X-Cherry-Timestamp | string | Unix epoch seconds at signing time. |
| X-Cherry-Signature | string | sha256=<hex> HMAC over `${timestamp}.${rawBody}` with your webhook secret. |
| Content-Type | string | Always application/json. Read the raw bytes for signature verification. |
verifyWebhook(opts)
Verify the HMAC-SHA256 signature and timestamp drift in constant time.
Signature
verifyWebhook(opts: VerifyWebhookOptions): booleanVerifyWebhookOptions:
| Field | Type | Required | Default | Notes |
| ------------------- | ----------------------------------------------- | -------- | ------- | ----------------------------------------------------------- |
| rawBody | string | ✔ | — | Raw POST body — exactly what the server signed. |
| headers | Record<string, string \| string[] \| undefined> | ✔ | — | HTTP request headers (case-insensitive lookup is applied). |
| secret | string | ✔ | — | Webhook secret from your app configuration. |
| toleranceSeconds | number | ✘ | 300 | Allowed clock drift in seconds. Drift > tolerance → false.|
Returns: true if signature and timestamp are both valid; false on
any failure (missing headers, malformed timestamp, drift exceeded, signature
mismatch). Does not throw.
parseWebhook(rawBody)
Parse the body into a typed discriminated union. Call after verifying.
Signature
parseWebhook(rawBody: string): AppWebhookPayloadThrows CherryAppsError with code: 'INVALID_PAYLOAD' when:
- Body is not valid JSON.
- Envelope is missing
event,deliveryId,timestamp, ordata. - Field types are wrong (e.g.
timestampis not a number).
Returns: the parsed payload, typed as AppWebhookPayload (discriminated
union — narrow by payload.event).
Webhook event payloads
The envelope shape is constant:
{ event: AppWebhookEventType;
deliveryId: string;
timestamp: number; // unix epoch seconds
data: <event-specific>; }Per-event data shapes:
| event | data interface | Fields |
| -------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------- |
| message.created | WebhookMessageCreatedData | roomId, messageId, senderId, content, messageType?, metadata?, attachments?, createdAt: number |
| message.deleted | WebhookMessageDeletedData | roomId, messageId, deletedBy |
| member.joined | WebhookMemberJoinedData | roomId, userId, role?: AppMemberRole \| 'owner' |
| member.left | WebhookMemberLeftData | roomId, userId |
| member.kicked | WebhookMemberKickedData | roomId, userId, kickedBy |
| member.banned | WebhookMemberBannedData | roomId, userId, bannedBy |
| member.unbanned | WebhookMemberUnbannedData | roomId, userId, unbannedBy |
| member.role_changed | WebhookMemberRoleChangedData | roomId, userId, role: AppMemberRole \| 'owner', changedBy |
| member.muted | WebhookMemberMutedData | roomId, userId, mutedBy, untilTs?: string |
| member.unmuted | WebhookMemberUnmutedData | roomId, userId, unmutedBy |
| room.settings_updated | WebhookRoomSettingsUpdatedData | roomId, changes: Record<string, unknown> |
| room.deleted | WebhookRoomDeletedData | roomId |
| app.attached_to_room | WebhookAppAttachedToRoomData | roomId, appId, ownerWallet, attachedAt: number |
| app.detached_from_room | WebhookAppDetachedFromRoomData | roomId, appId, ownerWallet, detachedAt: number, detachedBy: 'owner' \| 'admin' \| 'app' |
| assignment_invite.accepted | WebhookAssignmentInviteAcceptedData | roomId, appId, inviteId?: string |
| assignment_invite.rejected | WebhookAssignmentInviteRejectedData | roomId, appId, inviteId?: string |
Type AppWebhookEventType: string literal union of all 16 event names above.
Type AppWebhookPayload: discriminated union — event is the
discriminator. Use a switch over payload.event to narrow.
Full webhook handler example
import express from 'express';
import { verifyWebhook, parseWebhook, CherryAppsError } from '@cherrydotfun/apps-sdk';
const app = express();
app.post(
'/cherry-webhook',
express.raw({ type: 'application/json' }), // raw bytes — required
(req, res) => {
const rawBody = (req.body as Buffer).toString('utf8');
if (!verifyWebhook({
rawBody,
headers: req.headers,
secret: process.env.CHERRY_WEBHOOK_SECRET!,
})) {
return res.status(401).end();
}
let event;
try {
event = parseWebhook(rawBody);
} catch (err) {
if (err instanceof CherryAppsError && err.code === 'INVALID_PAYLOAD') {
return res.status(400).end();
}
throw err;
}
switch (event.event) {
case 'member.joined':
console.log('Joined:', event.data.userId, 'role:', event.data.role);
break;
case 'member.kicked':
console.log('Kicked:', event.data.userId, 'by:', event.data.kickedBy);
break;
case 'message.created':
// event.data.metadata may carry senderType === 'app_bot' etc.
console.log('Msg:', event.data.content);
break;
case 'app.detached_from_room':
// Distinguish source: 'owner' (user disconnected),
// 'admin' (force-detach), 'app' (reserved/future).
console.log('Detached by:', event.data.detachedBy);
break;
// ... handle the rest as needed
}
res.status(204).end();
},
);
app.listen(4000);Types reference
Entities
AppRoom
| Field | Type | Notes |
| ---------------------- | ------------------------------- | ---------------------------------------------------- |
| id | string | Room ID |
| type | string | Room type, usually 'group' |
| owner | string | Owner wallet |
| title | string | Group name |
| description | string \| undefined | |
| avatar | string \| undefined | Legacy avatar field |
| avatarUrl | string \| undefined | |
| settings | AppRoomSettings \| undefined | See below |
| gatingRule | Record<string, unknown> | Token-gating config |
| app | RoomApp \| undefined | App binding (see RoomApp below) |
| memberCount | number \| undefined | |
| lastMessage | AppLastMessage \| undefined | Denormalised most-recent message |
| createdAt | string \| Date \| undefined | |
| updatedAt | string \| Date \| undefined | |
RoomApp
Nested under AppRoom.app. Present only when the room is managed by an app.
| Field | Type | Notes |
| ---------------------- | ---------------------- | --------------------------------------------------------------------------- |
| appId | string | Managing app ID (matches your app) |
| appCreated | boolean | true if your app created this room; false if assigned by owner |
| allowOwnerDetach | boolean \| undefined | Owner can disconnect the app. Default false for app-created rooms. See locks |
| allowOwnerDelete | boolean \| undefined | Owner can delete the group. Default false |
| allowOwnerTransfer | boolean \| undefined | Owner can transfer ownership. Default false |
Reading locks:
const { room } = await cherry.groups.get(roomId);
if (room.app?.appCreated) {
console.log('Owner detach allowed?', room.app.allowOwnerDetach ?? false);
}AppRoomSettings
interface AppRoomSettings {
isPublic?: boolean;
maxMembers?: number;
[key: string]: unknown; // free-form extension
}AppLastMessage
| Field | Type | Notes |
| ------------ | --------------------- | -------------------------------- |
| id | string | Message ID |
| senderId | string | Wallet that sent the message |
| content | string | Plain-text body |
| createdAt | string \| Date | Sent at |
AppMessage
| Field | Type | Notes |
| -------------- | --------------------------------- | ------------------------------------------------------------------ |
| id | string | Message ID |
| roomId | string | Owning room |
| senderId | string | Server-derived sender |
| content | string | Plain-text body |
| messageType | string \| undefined | Optional discriminator |
| metadata | Record<string, unknown> \| undefined | For app-bot sends, the server merges in senderType: 'app_bot', appId, appBotDisplayName, appBotAvatarUrl |
| attachments | unknown[] \| undefined | Reserved |
| createdAt | string \| Date | |
| deleted | boolean \| undefined | True if the message has been soft-deleted |
AppMember
| Field | Type | Notes |
| -------------- | --------------------------------- | ------------------------------------------------------------------ |
| userId | string | Member wallet |
| roomId | string | |
| status | string | E.g. 'active', 'requested', 'rejected' |
| role | AppMemberRole \| 'owner' | Effective role |
| banned | boolean \| undefined | |
| muted | boolean \| undefined | |
| mutedUntil | string \| Date \| undefined | Expiry of timed mute |
AppMemberRole
type AppMemberRole = 'admin' | 'moderator' | 'member';
// Note: 'owner' is intentionally absent — not assignable via SDK.AppMeResponse & AppPublicInfo
See client.me() and client.getPublicAppInfo(appId).
Inputs
Summary (see per-method sections above for full details):
| Type | Used by |
| --------------------- | -------------------------------------------------------------- |
| CreateGroupInput | groups.create |
| UpdateGroupInput | groups.update |
| InviteMembersInput | members.invite |
| BanMemberInput | members.ban |
| MuteMemberInput | members.mute |
| SetRoleInput | (internal — members.setRole accepts role as a parameter) |
| SendMessageInput | messages.send |
Queries
| Type | Used by | Fields |
| ------------------- | ------------------------ | ---------------------------- |
| ListGroupsQuery | groups.list | cursor?, limit? |
| ListMessagesQuery | messages.list | before?, limit? |
Responses
| Type | Used by | Shape |
| -------------------------- | ------------------------ | ---------------------------------------------- |
| CreateGroupResponse | groups.create | { roomId } |
| ListGroupsResponse | groups.list | { rooms: AppRoom[], nextCursor? } |
| GetGroupResponse | (internal) | { room: AppRoom } |
| InviteMembersResponse | members.invite | { invited, skipped, accepted } |
| ListMessagesResponse | messages.list | { messages: AppMessage[], nextCursor? } |
| AppMeResponse | client.me | { appId, scopes, botWallet, botDisplayName?, rateLimits? } |
| AppPublicInfo | client.getPublicAppInfo| { name, botDisplayName?, botAvatarUrl?, ownerWallet?, botWallet? } |
Error handling
All SDK methods that hit the server throw CherryAppsError on non-2xx
responses. Webhook helpers throw CherryAppsError with code
INVALID_PAYLOAD for malformed bodies; verifyWebhook never throws.
CherryAppsError
class CherryAppsError extends Error {
readonly name: 'CherryAppsError';
readonly message: string; // human-readable
readonly code: string; // machine-readable, e.g. 'ROOM_NOT_FOUND'
readonly status: number; // HTTP status (0 for network / timeout / parse errors)
readonly details: unknown; // optional server-provided context
}| code | status | Meaning |
| ----------------------------------- | -------- | ------------------------------------------------------------------------- |
| REQUEST_TIMEOUT | 0 | Aborted by AbortController after config.timeout ms |
| NETWORK_ERROR | 0 | fetch rejected (DNS, TCP, TLS, etc.) |
| INVALID_PAYLOAD | 0 | parseWebhook got malformed JSON / missing envelope fields |
| HTTP_ERROR | various | Server returned non-2xx without a structured {error, message} envelope |
| MISSING_SCOPE | 403 | App token lacks the required scope |
| RATE_LIMITED | 429 | Per-minute / per-day rate limit hit (Retry-After header returned) |
| ROOM_NOT_MANAGED_BY_APP | 403 | Cross-app isolation — room belongs to a different app |
| ROOM_NOT_FOUND | 404 | Room does not exist |
| MEMBER_NOT_FOUND | 404 | Wallet is not a member of the room |
| CANNOT_MODERATE_OWNER | 403 | Cannot moderate the room owner |
| CANNOT_DELETE_ASSIGNED_ROOM | 403 | Cannot delete a room obtained via assignment |
| INVALID_ROLE | 400 | setRole cannot assign 'owner' |
| DETACH_DISABLED_BY_APP | 403 | Owner tried to disconnect a locked room (!allowOwnerDetach) |
| DELETE_DISABLED_BY_APP | 403 | Owner tried to delete a locked room (!allowOwnerDelete) |
| TRANSFER_OWNER_DISABLED_BY_APP | 403 | Owner tried to transfer ownership of a locked room (!allowOwnerTransfer)|
| INVALID_INPUT | 400 | DTO validation failed (missing/wrong-type fields) |
Example
import { CherryAppsError } from '@cherrydotfun/apps-sdk';
try {
await cherry.groups.get('does-not-exist');
} catch (err) {
if (err instanceof CherryAppsError) {
console.log(err.status); // 404
console.log(err.code); // 'ROOM_NOT_FOUND'
console.log(err.message);
console.log(err.details);
}
throw err;
}Playground
The example/ directory contains a React playground that
exercises every SDK method through forms and streams live webhooks over
Server-Sent Events. Useful for first-time setup and exploring response
shapes without writing glue code.
cd example
cp .env.example .env # fill CHERRY_APP_KEY + CHERRY_WEBHOOK_SECRET
bun install
bun run dev # http://localhost:5173Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.
