Skip to content

cherrydotfun/apps-sdk

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@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 fetch and crypto).
  • No runtime dependencies.
  • ESM + CJS dual build, full TypeScript typings.

Table of Contents


Install

npm  install @cherrydotfun/apps-sdk
yarn add     @cherrydotfun/apps-sdk
pnpm add     @cherrydotfun/apps-sdk
bun  add     @cherrydotfun/apps-sdk

Requirements: 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) for appCreated:true rooms.
  • 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 later groups.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 with detachedBy: '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): boolean

VerifyWebhookOptions:

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): AppWebhookPayload

Throws CherryAppsError with code: 'INVALID_PAYLOAD' when:

  • Body is not valid JSON.
  • Envelope is missing event, deliveryId, timestamp, or data.
  • Field types are wrong (e.g. timestamp is 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:5173

Changelog

See CHANGELOG.md.

License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages