Notification inbox
Everything you have sent somebody, on their own device, as data. You draw the screen.
The SDK returns rows and a cursor and has no opinion about what a notification looks like — no component, no styling, no navigation. What it handles is the part an app cannot write for itself: a paged server-side history, read state that follows a person across their devices, a first page that still works on a train, and a subscription so an unread badge does not need a timer.
Reading the list
One call. Omit the cursor for the first page and pass the one you were given for the next; nextCursor is null at the end. The end is as far back as your plan keeps deliveries: the history, and the unread count with it, cover that window rather than everything ever sent.
import treebars, { type NotificationPage } from '@treebars/react-native-sdk';
const page = await treebars.notifications.list();
// { notifications, unreadCount, nextCursor, fromCache }
// The next page:
const more = await treebars.notifications.list({ cursor: page.nextCursor });A whole screen, end to end. This is the entire integration — everything else on this page is detail you may not need.
function NotificationCentre() {
const [page, setPage] = useState<NotificationPage | null>(null);
useEffect(() => {
void treebars.notifications.list().then(setPage);
// Fires on a fetch, on any mark, and on sign-out. No polling.
return treebars.notifications.onChange(setPage);
}, []);
return (
<FlatList
data={page?.notifications ?? []}
keyExtractor={(n) => n.group_id}
onEndReached={async () => {
if (!page?.nextCursor) return;
const next = await treebars.notifications.list({ cursor: page.nextCursor });
setPage({ ...next, notifications: [...page.notifications, ...next.notifications] });
}}
renderItem={({ item }) => (
<Pressable onPress={() => treebars.notifications.markRead(item.group_id)}>
<Text style={{ fontWeight: item.read_at ? '400' : '700' }}>{item.content.title}</Text>
<Text>{item.content.body}</Text>
</Pressable>
)}
/>
);
}Somebody signed in
In a live environment a sign-in is recorded, and that person's inbox served, only when your backend vouches for them. Your write key is public, so an app naming an account proves nothing; your server signs the account id with the environment's identity secret, and your app passes the signature to identify. Without it the sign-in is refused, the list comes back empty, and the SDK logs why, once. A test environment does not ask unless you turn it on — do, before you ship, to see what production will do.
import { createHmac } from 'node:crypto';
// Hex HMAC-SHA256 of the account id. Keep the secret on the server.
const signature = createHmac('sha256', process.env.TREEBARS_IDENTITY_SECRET!)
.update(user.id)
.digest('hex');// React Native
await treebars.identify(user.id, { plan: 'pro' }, signature);
// Web
treebars.identify(user.id, { plan: 'pro' }, signature);
// iOS
Treebars.identify(user.id, attributes: ["plan": "pro"], signature: signature)
// Android
Treebars.identify(user.id, mapOf("plan" to "pro"), signature)What a row carries
| Field | What it is |
|---|---|
group_id | One send. The id every method here takes — see below. |
channel_type | push or in_app. |
device_count | How many of this person’s devices the send reached. |
content | title, body, image_url, deep_link, data, and in_app on an in-app message. |
read_at | null until somebody reads it, on any of their devices. |
opened_at | Set when a push was tapped in the tray, not when it was read here. |
created_at | When the send was dispatched. The list is ordered by it, newest first. |
expires_at | In-app only, and an expired message stays in the history. |
- Field
group_id- What it is
- One send. The id every method here takes — see below.
- Field
channel_type- What it is
pushorin_app.- Field
device_count- What it is
- How many of this person’s devices the send reached.
- Field
content- What it is
title,body,image_url,deep_link,data, andin_appon an in-app message.- Field
read_at- What it is
nulluntil somebody reads it, on any of their devices.- Field
opened_at- What it is
- Set when a push was tapped in the tray, not when it was read here.
- Field
created_at- What it is
- When the send was dispatched. The list is ordered by it, newest first.
- Field
expires_at- What it is
- In-app only, and an expired message stays in the history.
Why a group id and not a delivery id
A push to somebody holding a phone and a tablet is two deliveries — that is deliberate, so an open on one does not mark the other. It is one notification, though, and a feed that showed it twice would also count it twice in the badge. So the inbox is keyed by the send, and device_count tells you how many devices it reached.
Read, unread, and removing
await treebars.notifications.markRead(groupId);
await treebars.notifications.markAllRead();
await treebars.notifications.dismiss(groupId);
const unread = await treebars.notifications.unreadCount();All of it is per person, not per device: reading on a phone is read on a tablet. Marking is applied locally first, so a row stops looking unread the moment it is tapped, and reconciles with the server behind it.
dismiss removes a notification for good. An in-app message removed here also leaves that person's in-app queue, which is what pressing a bin is understood to mean.
Keeping a badge current
onChange fires on a fetch, on any mark you make, and on sign-out. It returns its own unsubscribe, and it exists so a badge never needs a timer.
useEffect(
() => treebars.notifications.onChange((page) => setUnread(page.unreadCount)),
[],
);Offline
The first page is kept on the device, so opening the screen without a network shows what was there last time, flagged fromCache: true so you can say so rather than imply a fresh read. Later pages are network-only — what a bell needs in a tunnel is its first screen, not unbounded storage — and a cursored call with no network hands your cursor back instead of throwing, so you can simply retry.
In-app messages in the inbox
Only messages authored for the inbox surface appear. An overlay is a moment — it interrupts once when its trigger fires and is dismissed per device — so listing it would misrepresent both what it is and what dismissing it means. Push has no surface and is always listed.
title and no body — the markup is the whole message, and that is valid. Check content.in_app.body_mode before drawing a title, or rows written that way render blank.import { inAppBodyMode, inAppHtmlDocument } from '@treebars/react-native-sdk';
inAppBodyMode(item.content.in_app) === 'html' ? (
// Draw it in a sandbox: no JavaScript, no navigation, and a fixed height,
// because a WebView has no intrinsic one and a list needs to know row heights.
<WebView
originWhitelist={['*']}
source={{ html: inAppHtmlDocument(item.content.in_app?.html ?? '') }}
javaScriptEnabled={false}
style={{ height: 64 }}
/>
) : (
<Text>{item.content.title}</Text>
)The other SDKs
The same surface, said the way each language says it.
let page = try await Treebars.notifications(limit: 25)
try await Treebars.markNotificationRead(page.notifications[0].group_id)
let stop = Treebars.onNotificationsChange { page in badge = page.unreadCount }val page = Treebars.notifications(limit = 25)
Treebars.markNotificationRead(page.notifications[0].groupId)
val stop = Treebars.onNotificationsChange { badge = it.unreadCount }const page = await treebars.notifications.list();
await treebars.notifications.markRead(page.notifications[0].group_id);
const stop = treebars.notifications.onChange((p) => setBadge(p.unreadCount));groupId rather than group_id, following Kotlin convention. Everything else matches.What is not in the inbox
Only what actually reached somebody. A message that failed at the provider, one held by quiet hours or a frequency cap, one suppressed because the person unsubscribed, and a test send from the dashboard are all absent — they belong in your delivery log, not in a customer's bell. Email and SMS are not here either: those have an inbox already.