birdcash-chat-sdk-alpha
v4.0.1
Published
TypeScript SDK for sending messages through the chat API (text, images, files, miniapp cards, streaming).
Readme
birdcash-chat-sdk
A small TypeScript SDK for sending messages through the chat API. It wraps the
/v1/chat/messages/:eventID/reply and /v1/upload/* endpoints so you can send
text, images, files, miniapp cards, web-link previews, typing status, and
streaming (typewriter) responses.
Install
npm install birdcash-chat-sdk-alphaQuick start
import { ChatClient } from "birdcash-chat-sdk-alpha";
const chat = new ChatClient({
token: "<bearer-token>",
// baseUrl: 'https://your-chat-api', // optional, defaults to the hosted API
// logger: console, // optional, omit for silence
});
// …or exchange OAuth client credentials for a token automatically:
const chat2 = await ChatClient.fromCredentials({ clientId, clientSecret });
// Send text (string = one element; array = one request, one element per item)
await chat.sendMessage(eventID, "Hello there!"); // webhook envelope id (`evt_…`)
await chat.sendMessage(eventID, ["First line", "Second line"]);Options
| Option | Type | Default | Description |
| --------- | -------------- | --------------- | ------------------------------------------ |
| token | string | — (required) | Bearer token sent with every request. |
| baseUrl | string | hosted endpoint | Base URL of the chat API. |
| logger | Logger | undefined | Pass console to log; omit for no output. |
| fetch | typeof fetch | global fetch | Custom fetch (Node < 18, tests, etc.). |
Sending messages
// Images: upload first, then send by upload_id
const { upload_id } = await chat.uploadImage(bytes, "photo.png", "image/png");
await chat.sendImageMessage(eventID, [upload_id]);
// Files
const file = await chat.uploadFile(bytes, "report.pdf", "application/pdf");
await chat.sendFileMessage(eventID, [file.upload_id]);
// Sound (uploaded via uploadFile; duration in seconds)
const audio = await chat.uploadFile(bytes, "clip.m4a", "audio/m4a");
await chat.sendSoundMessage(eventID, audio.upload_id, 12);
// Sticker (by pack + sticker identifiers)
await chat.sendStickerMessage(eventID, "weather", "Cloud with lightning");
// Typing indicator (ephemeral `event.typing`; same webhook eventID as reply)
await chat.sendTypingStatus(eventID, true);
await chat.sendTypingStatus(eventID, false);
// Miniapp card
await chat.sendMiniAppMessage(eventID, {
app_id: "app123",
title: "Open the app",
path: "/home",
image_url: "https://…/cover.png",
});
// Link preview card (`link.preview`)
await chat.sendWebLinkMessage(eventID, "https://example.com");
// or with optional metadata:
await chat.sendWebLinkMessage(eventID, {
url: "https://example.com",
title: "Example",
});
// Choices prompt (`ai.choicePrompt`, bot accounts) — returns the new message id.
// Selection is applied via WS `message.interact`, not by editing the card.
const choiceMsgID = await chat.sendChoicesMessage(eventID, {
prompt: "Which shipping option do you want?",
choices: [
{ id: "standard", label: "Standard (5–7 days)" },
{ id: "express", label: "Express (2 days)" },
],
// selectionMode: 'single', // optional, defaults to 'single'
});
// Official-account post (`oa.post`) — server-origin only; client/bot REST sends
// are rejected by the registry.
await chat.sendOfficialAccountMessage(eventID, {
title: "Release notes",
body: "What shipped this week…",
link: "https://example.com/blog",
});Note cards
Styled text cards rendered by the server. You send text and a style id — never
pixels — and get back staged upload_ids that attach like any other image.
// The style registry. Read it rather than hardcoding ids: styles get added, and
// `groups` is the order a picker should render its sections in.
const { styles, groups, canvas } = await chat.noteStyles();
// Preview the user's own text in a style. Nothing is staged, so a picker can
// render every style without burning an upload_id per swatch.
const png = await chat.noteCardPreview({ text: "Back in stock.", style: "navy" });
// Render and stage. One upload_id per card, in order.
const { upload_ids } = await chat.uploadNoteCards([
{ text: "Starting the morning off right.", style: "navy" },
{ text: "New drop this week.", style: "plain-statement" },
]);
// Or render and send as a chat message in one call.
await chat.sendNoteCardMessage(eventID, [
{ text: "Back in stock.", style: "navy", highlight_words: ["stock"] },
]);Per-card overrides are highlight_words, highlight_color, highlight_style
(marker or color), uppercase and sticker. Everything else — font, ground,
alignment, sizing — belongs to the style. Text is capped at 600 characters.
canvas is portrait (default, 4:5), square, or story (9:16), and applies
to the whole request so a carousel cannot end up with mixed aspect ratios.
Note posts
import { createNotePost, getNoteStyles, noteCardPreviewURL } from "birdcash-chat-sdk";
await createNotePost(token, {
title: "New drop this week.",
// Hashtags live on the post caption — card text is pixels and is not parsed.
content: "Heavyweight hoodie, back in stock. #fw26",
notes: [
{ text: "Front.", style: "navy" },
{ text: "New drop this week.", style: "plain-statement" },
],
});
// For a picker UI, put preview URLs straight in <img src> instead of fetching.
const src = noteCardPreviewURL({ text: draft, style: "butter" });Every commerce helper takes an optional trailing { baseUrl, fetch } so a call
that spans two endpoints — like createNotePost, which uploads then posts —
sends both halves to the same host.
Editing & deleting
Send with a known replyMsgID, then edit that message in place (or delete it):
const editable = crypto.randomUUID();
await chat.sendMessage(eventID, "Working on it…", editable);
await chat.editTextMessage(editable, "Done ✅");
// Stickers and images edit in place too
await chat.sendStickerMessage(eventID, "capoo", "capoo_1", editable);
await chat.editStickerMessage(editable, "capoo", "capoo_2");
await chat.editImageMessage(imgMsgID, [upload_id]);
// Delete a message you sent
await chat.deleteMessage(editable);Streaming (typewriter effect)
Streams have their own routes (docs/api/chat/stream-frames.md): one message,
opened once on POST /v1/chat/streams — the server names it and returns the id
— and advanced by frames on POST /v1/chat/streams/{id}/frames. A frame names a contiguous revision and
any of four channels — answer, reasoning, sources, activity — each with
{set} or {append}. Only a terminal status is ever named; the server derives
pending/streaming.
// Recommended: reply with a throttled token stream
const streamId = await chat.sendStreamMessage(eventID, "A long streamed reply…");
// Low-level: drive each frame yourself
const { streamId } = await chat.openStream({ eventId });
await chat.postFrame(streamId, deltaFrame(1, appendReasoning("The user wants a capital. ")));
await chat.postFrame(streamId, deltaFrame(2, appendAnswer("Paris"), setReasoning("")));
await chat.postFrame(streamId, deltaFrame(3, appendSources([{ title: "Source", url: "https://…" }])));
await chat.settle(streamId, 4); // no text: the server holds it
await chat.settle(streamId, 4, "complete", { repair: fullText }); // when a delta was lost
// Long quiet stretch
const ack = await chat.heartbeat(streamId);
if (ack.cancelRequested) { /* the user pressed stop; nothing more will be accepted */ }
// After a timeout
const { snapshot } = await chat.getStream(streamId);Streaming a reply
streamWriter is the driver: it owns revisions, buffering and its cadence, the
heartbeat that holds the lease through a long think, the abort that stops your
model when the user presses stop, and a settle that closes the stream even when
the server refuses what it carried.
const stream = await chat.streamWriter(replyToFrom(msgID), { placeholder: "Thinking…" });
try {
for await (const chunk of model({ signal: stream.signal })) {
await stream.answer.append(chunk);
}
await stream.settle();
} catch (e) {
await stream.fail();
}stream.answer and stream.reasoning take append / set / flush;
stream.sources and stream.activity take push / set. Pass stream.signal
to whatever produces tokens — without it a cancelled generation keeps running.
Frames are safe to retry at the same revision: the server recognizes a
replay and answers success, refuses a reused revision (REVISION_TAKEN),
refuses a skipped one (REVISION_GAP) unless the frame repairs with
answer.set, and says how long to wait on 429. postFrame/settle throw
StreamFrameRejected — isConflict, isRevisionTaken, isCancelled,
isRateLimited (with retryAfterMs) and isPermanent name the cases.
sendStreamMessage handles all of it.
Frames on one stream must be at least MIN_FRAME_INTERVAL_MS (50 ms) apart, and
postFrame waits that out for you before sending — so a progress line followed
immediately by an answer you formatted in memory is paced rather than refused. A
429 that gets through anyway is retried with the same frame, because a refused
frame does not land and its content would otherwise be lost.
OAuth
If you don't already have a bearer token, exchange client credentials for one:
import { getAccessToken } from "birdcash-chat-sdk-alpha";
const token = await getAccessToken({
clientId,
clientSecret,
scope: "chat:write uploads:write", // default
});
// token.access_token, token.expires_in, …Verifying webhooks
Validate incoming webhook requests before trusting them. The signature scheme
matches the server: HMAC-SHA256(secret, "${timestamp}.${rawBody}"), sent in the
X-Webhook-Signature (optionally sha256=-prefixed) and X-Webhook-Timestamp
headers. Pass the raw body text, read before JSON-parsing.
import {
verifyWebhookSignature,
isLedgerWebhookEvent,
type WebhookEventBody,
} from "birdcash-chat-sdk-alpha";
const raw = await request.text();
const { valid, error } = await verifyWebhookSignature(
request,
env.WEBHOOK_SECRET,
raw,
);
if (!valid) return new Response(error ?? "bad signature", { status: 401 });
const event = JSON.parse(raw) as WebhookEventBody;
if (event.type === "message.new") {
const { msg_id, text_elem } = event.payload;
// plaintext: text_elem?.text
// E2EE: text_elem?.encrypted_payload (snake_case nested keys)
}
if (isLedgerWebhookEvent(event)) {
// Money movement: defining-leg `amount` (minor units) + `transactions`.
const { event_type, amount, order_id, transactions } = event.payload;
}message.new payload fields are snake_case (msg_id, elem_type,
text_elem, encrypted_payload, …) — same convention as REST reply bodies.
Types: MessageNewPayload / MessageNewBody (also exported from ./webhook).
Shared places (location_elem)
A place arrives on location_elem, in one of two shapes: the four fields in the
clear, or every field null and the whole place sealed in encrypted_payload —
the same envelope text_elem uses, because a place is small enough to encrypt
whole (no blob to fetch). Which one you get is the sending client's per-conversation
choice; public and community groups are always plaintext.
readInboundLocation takes either, so a handler need not branch:
import { readInboundLocation } from "birdcash-chat-sdk-alpha";
const place = await readInboundLocation(event.payload, {
chatPrivateKey: env.CHAT_KEY_PRIVATE, // only needed for a sealed place
});
if (place) {
console.log(place.name ?? `${place.latitude}, ${place.longitude}`);
}It returns null rather than throwing when there is no place, no key, or no
envelope this key can open. Use decryptInboundLocationMessage when you want the
error — it takes the same options as decryptInboundTextMessage and returns
{ latitude, longitude, name?, address? }.
Two things worth knowing. location_elem is not location — the latter is
where the sender's session is connecting from, resolved server-side, and has
nothing to do with a shared pin. And the coordinate rules the server applies to a
plaintext place cannot run on a sealed one, so decryptInboundLocationMessage
does the minimum here instead: ciphertext that opens to something without a
finite coordinate is rejected rather than handed back as a pin at (0, 0).
Bots can receive places but not send them: the reply rail has never built location messages.
ledger.* is the money-movement rail (one POST per committed ledger-event
status). A capture fires both order.captured and
ledger.order_capture.completed; the latter carries merchant net proceeds and
the platform-fee leg. Types: LedgerEventPayload / LedgerEventBody /
isLedgerWebhookEvent.
It rejects missing headers, bad signatures (constant-time compare), and stale
timestamps (replay protection, default 300s — override with { toleranceSec }).
Pass { logger: console } for diagnostics.
Lower-level exports
The element builders and helpers are also exported if you want to assemble requests yourself:
import {
ElemType,
textElem,
imageElem,
splitIntoChunks,
toBase64,
} from "birdcash-chat-sdk-alpha";