npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 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.