beeper-sdk
v0.3.0
Published
Server-side TypeScript SDK for Beeper notification APIs.
Readme
beeper-sdk
Server-side TypeScript SDK for Beeper notification APIs.
Install
pnpm add beeper-sdkUsage
import { BeeperClient } from "beeper-sdk";
const beeper = new BeeperClient({
projectId: process.env.BEEPER_PROJECT_ID!,
apiKey: process.env.BEEPER_SERVER_KEY!,
});baseUrl is optional and defaults to the hosted Beeper API — you do not need to know or
configure the API origin. Pass it only when self-hosting, or to target a staging or local
deployment:
const beeper = new BeeperClient({
baseUrl: process.env.BEEPER_BASE_URL, // optional override
projectId: process.env.BEEPER_PROJECT_ID!,
apiKey: process.env.BEEPER_SERVER_KEY!,
});
await beeper.upsertSubscriber({
subscriberId: "user_123",
displayName: "John Doe",
email: "[email protected]",
});
await beeper.triggerNotification({
channels: ["in_app", "email"],
subscriberIds: ["user_123"],
templateKey: "welcome",
category: "transactional",
actionLabel: "Open dashboard",
actionUrl: "https://app.example.com/dashboard",
});
await beeper.registerPushDevice({
subscriberId: "user_123",
pushToken: "<fcm_token>",
platform: "web",
});
await beeper.triggerNotification({
channels: ["push"],
pushTokens: ["<fcm_token_1>", "<fcm_token_2>"],
templateKey: "promo-push",
category: "transactional",
actionLabel: "View offer",
actionUrl: "https://example.com/offers/spring",
});actionLabel and actionUrl are stored in notification metadata and passed through to
in-app inbox items. The inbox widget renders them as an action button/link when present.
Push images and custom sounds
push controls how the operating system presents a push. It requires "push" in channels
and does not affect the in-app inbox:
await beeper.triggerNotification({
channels: ["push"],
subscriberIds: ["user_123"],
templateKey: "order-shipped",
push: {
imageUrl: "https://cdn.example.com/orders/123.jpg", // https only, max 1024 chars
sound: "chime", // bundled in the app: res/raw/chime.* and chime.caf
androidChannelId: "orders", // Android 8+ takes the sound from this channel
},
});Images on iOS need a Notification Service Extension in the app, custom sounds must be bundled in the app, and browsers support neither custom sounds nor (outside Chromium) images. Invalid values reject the trigger with a message naming the field. Full setup: Push images and custom sounds.
Action buttons
push.actions puts up to three buttons on the notification. Beeper does not route presses:
the OS hands the pressed id to your app, which handles it.
push: {
actions: [
{ id: "accept", label: "Accept" },
{ id: "reject", label: "Refuse", destructive: true },
],
iosCategoryId: "order_new", // iOS shows the buttons of the category your app registered
androidDataOnly: true, // Android: your app draws the notification (e.g. Notifee)
},Both platforms need app-side code, and androidDataOnly hides the notification on Android
until your app draws it. Full setup: Push action buttons.
Inbox images, grouping and metadata
await beeper.triggerNotification({
channels: ["in_app", "push"],
subscriberIds: [sellerId],
templateKey: "order-new",
groupKey: `orders:${businessId}`,
inbox: {
avatarUrl: buyer.photoUrl, // small, beside the title
imageUrl: product.photoUrl, // large, under the body
},
metadata: { kind: "order", order: { id: orderId, total: { amount: 3200, currency: "HTG" } } },
});inbox.imageUrl/inbox.avatarUrlbecomeimageUrl/avatarUrlon the inbox item, and the built-in React and native inboxes show them. They must be https, likepush.imageUrl, which stays push-only. You can change them later withupdateInboxItem.groupKeyfolds related notifications. iOS stacks pushes that share it, Android apps receive it as_beeperGroupKey, and inbox items carry it asgroupKey. Fold rows withgroupInboxItems:import { groupInboxItems } from "beeper-sdk"; for (const group of groupInboxItems(items, { windowMs: 24 * 60 * 60 * 1000 })) { // group.latest, group.count, group.unreadCount → "Marie and 4 others" }metadatacan be nested. The inbox item keeps it exactly as sent. Only the push data payload is flattened, because FCM data values must be strings: top-level strings arrive as they are, and anything else arrives JSON-encoded, soJSON.parse(data.order)recovers it.
Sending at a local time
sendAtLocalTime turns a wall-clock time in a timezone into a sendAt, allowing for daylight
saving time, so a daily digest lands at 8pm local all year:
import { sendAtLocalTime } from "beeper-sdk";
await beeper.scheduleNotification({
channels: ["push", "in_app"],
subscriberIds: [seller.id],
templateKey: "daily-digest",
sendAt: sendAtLocalTime({ timeZone: seller.timeZone, time: "20:00" }), // next 8pm there
idempotencyKey: `digest:${seller.id}:${today}`,
});Without date, it returns the next occurrence: today if that time hasn't passed there yet,
otherwise tomorrow. Pass date: "YYYY-MM-DD" for a specific day. timeZone is an IANA name
such as "America/Port-au-Prince", which you store per user. Beeper doesn't keep a timezone on
subscribers: schedule one notification per timezone group, or per subscriber for personalized
digests.
Updating an inbox item after it is sent
updateInboxItem edits delivered inbox items, so the inbox shows current state instead of
what was true at send time. Select by the idempotencyKey you triggered with, or by
inboxItemId, optionally narrowed with subscriberId:
await beeper.updateInboxItem(
{ idempotencyKey: `order:${orderId}:business:${businessId}` },
{
body: "Deja aksepte · 3,200 HTG",
metadata: { status: "accepted" }, // shallow merge; a null value deletes a key
actionLabel: "Wè kòmand",
actionUrl: `kmarkets://orders/${orderId}`,
resolved: true, // sets resolvedAt; the built-in inboxes fade the item
},
);title,actionLabel,actionUrl,imageUrlandavatarUrlacceptnullto remove them. Clear both action fields to remove the button.- Changing
metadata.kindmoves the item betweenkindfilters. - Only delivered items change. A notification still scheduled delivers its original content,
and
updatedis0. - One call updates at most 500 items; pass
subscriberIdto narrow a broadcast. - Needs a key with
ingest:write. REST:POST /api/v1/projects/:projectId/inbox-items/updatewith{ idempotencyKey | inboxItemId, subscriberId?, patch }.
Widget session route handler
POST /widget/session authenticates your server key, not your end user — it mints a session
for whatever subscriberId it is handed. Your route is the only thing that decides who the
caller is, so the id must come from your own auth session and never from the request body.
widgetSessionHandler enforces that by construction:
// app/api/beeper/widget-session/route.ts — Next.js App Router
export const POST = beeper.widgetSessionHandler({
authenticate: async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
return session ? { subscriberId: session.user.id } : null; // null → 401
},
});The handler never parses the request body, so there is no parameter through which a
caller-supplied subscriberId could enter. It takes and returns web-standard
Request/Response — the same export mounts in Next.js route handlers, Remix/React Router
actions, Hono, Cloudflare Workers, Bun and Deno.
// Remix / React Router
export const action = ({ request }: ActionFunctionArgs) => handler(request);
// Hono / Workers — build the client inside the handler, off the env binding
app.post("/api/beeper/widget-session", (c) =>
new BeeperClient({
projectId: c.env.BEEPER_PROJECT_ID,
apiKey: c.env.BEEPER_SERVER_KEY,
}).widgetSessionHandler({
authenticate: async (request) => {
const session = await auth.api.getSession({ headers: request.headers });
return session ? { subscriberId: session.user.id } : null;
},
})(c.req.raw),
);Options: ttlSeconds (clamped to 60-3600 by the API, default 900) and onError, which
receives the thrown error for your logger — the browser only ever sees a generic 500, never the
upstream message or your server key. Return profile from authenticate to sync the
subscriber's displayName/email/metadata at mint; that requires the subscribers:write
scope on the key.
Worth testing once per app: signed in as user A, curl your route with
{"subscriberId":"user_b"} — you must get back a token for A.
Widget session helpers
const session = await beeper.createWidgetSession({
subscriberId: "user_123",
ttlSeconds: 900,
});
await beeper.revokeWidgetSessions({ subscriberId: "user_123" });Push device helpers
await beeper.registerPushDevice({
subscriberId: "user_123",
pushToken: "<fcm_token>",
platform: "web",
});
await beeper.unregisterPushDevice({
subscriberId: "user_123",
pushToken: "<fcm_token>",
});
const pushStats = await beeper.getPushDeviceStats();Security
- Use this SDK only on trusted backend services.
- Never expose
BEEPER_SERVER_KEYin browser/mobile client bundles. - Rotate API keys regularly and revoke keys on suspicious activity.
Client-Safe Widget API
For browser and React Native clients, use the separate widget entry point with a publishable key and short-lived session token:
import { BeeperWidgetClient } from "beeper-sdk/widget";
const widget = new BeeperWidgetClient({
projectId,
publishableKey,
sessionToken,
});
const inbox = await widget.getInbox(userId);
await widget.markRead(userId, [inbox.items[0]._id]);Both clients' getInbox take a filter by kind (copied from metadata.kind at send time)
and page with an opaque cursor:
const orders = await widget.getInbox(userId, { kind: "order", limit: 20 });
if (orders.nextCursor) {
const older = await widget.getInbox(userId, { kind: "order", cursor: orders.nextCursor });
}kind also takes an array (up to 10). The older getInbox(userId, 50) form still works.
Unread counts per kind, for tab badges: name the kinds, alone or with the list.
const { unreadCount, byKind } = await widget.getUnreadCount(userId, { kinds: ["order", "chat"] });
const page = await widget.getInbox(userId, { kind: "order", unreadKinds: ["order", "chat"] });
page.unreadByKind; // { order: 3, chat: 12 }Archive items to dismiss them. They leave the default listing and count as read:
await widget.archive(userId, [itemId]); // or beeper.archiveInboxItems(userId, ids)
const archived = await widget.getInbox(userId, { archived: true });
await widget.unarchive(userId, [itemId]); // or beeper.unarchiveInboxItems(userId, ids)The widget client does not accept a server API key. Mint and refresh the session token from your own authenticated backend.
Errors
Failed requests throw BeeperApiError.
import { BeeperApiError } from "beeper-sdk";
try {
await beeper.getUsage();
} catch (error) {
if (error instanceof BeeperApiError) {
console.error(error.status, error.message, error.payload);
}
}Release checklist
pnpm -F beeper-sdk run clean
pnpm -F beeper-sdk run build
pnpm -F beeper-sdk run check-types