Skip to content
Private betaTreebars is letting teams in from the waitlist.Join the waitlist

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.

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

A notification centretsx
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.

Your server, at sign-ints
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');
The apptsx
// 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)
The secret is under Settings → Environments, one per environment, for an admin. Rotating it keeps the old one working for a day, so your backend can take the new one without anybody's inbox going blank. Signed sign-ins has the rest: other languages, when to sign, and what to check.

What a row carries

Field
group_id
What it is
One send. The id every method here takes — see below.
Field
channel_type
What it is
push or in_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, and in_app on an in-app message.
Field
read_at
What it is
null until 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.

Each device is shown the copy it was actually sent. If your campaign varies a push per platform, the iPhone sees the iOS wording and the Pixel sees the Android wording, from the same row.

Read, unread, and removing

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

ts
useEffect(
  () => treebars.notifications.onChange((page) => setUnread(page.unreadCount)),
  [],
);
Re-read the count when the signed-in person changes, too. Signing out clears the inbox and emits a zero — correct, since the history belonged to whoever left — but nothing fetches again on its own until you ask.

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.

A custom-HTML in-app message has no 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.
tsx
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.

iOSswift
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 }
Androidkotlin
val page = Treebars.notifications(limit = 25)
Treebars.markNotificationRead(page.notifications[0].groupId)
val stop = Treebars.onNotificationsChange { badge = it.unreadCount }
Webts
const page = await treebars.notifications.list();
await treebars.notifications.markRead(page.notifications[0].group_id);
const stop = treebars.notifications.onChange((p) => setBadge(p.unreadCount));
Android names the field 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.