Building a calls tab

A calls tab lists the user's recent calls: "Missed call from Alice", "Outgoing video call · 4:20", with a button to call back. poolse keeps the history for you. This guide turns it into that list.

The data

GET /v1/me/calls, with the user's JWT, returns every call and room the user placed or was part of, newest first. See Call history for every field and filter.

{
  "data": [
    {
      "id": "4a69…",
      "conversation_id": "6f1c…",
      "kind": "call",
      "media": "video",
      "status": "ended",
      "started_at": "2026-10-27T09:30:00.000000Z",
      "answered_at": "2026-10-27T09:30:05.000000Z",
      "ended_at": "2026-10-27T09:34:25.000000Z",
      "end_reason": "finished",
      "duration_ms": 260000,
      "started_by": { "id": "2b7e…", "external_id": "acme-alice" },
      "participants": [
        { "id": "9c41…", "external_id": "acme-bob", "status": "joined" },
        { "id": "a7d2…", "external_id": "acme-carol", "status": "missed" }
      ]
    }
  ],
  "next_cursor": "MjAyNi0xMC0yN1Qw…"
}

The SDK has no dedicated method for this endpoint. Call it through the SDK's REST client, which adds the user's token and retries for you:

import { usePoolse } from '@poolse/react';

type Person = { id: string; external_id: string | null };
type CallRecord = {
  id: string;
  conversation_id: string;
  kind: 'call' | 'room';
  media: 'audio' | 'video';
  status: 'live' | 'ended';
  started_at: string;
  answered_at: string | null;
  ended_at: string | null;
  end_reason: string | null;
  duration_ms: number | null;
  started_by: Person | null;
  participants: Array<Person & { status: 'joined' | 'declined' | 'busy' | 'missed' | 'invited' }>;
};

async function fetchCalls(poolse: ReturnType<typeof usePoolse>, cursor?: string) {
  return poolse.rest.request<{ data: CallRecord[]; next_cursor: string | null }>({
    method: 'GET',
    path: '/v1/me/calls',
    query: { limit: 30, cursor, kind: 'call' },
  });
}

kind: 'call' leaves out rooms. Most calls tabs show only calls that rang. Drop the filter to include rooms.

Word each row for the viewer

One record reads differently for each person in it. Work out the viewer's side first:

function rowFor(call: CallRecord, meId: string) {
  const outgoing = call.started_by?.id === meId;
  const mine = call.participants.find((p) => p.id === meId);
  const video = call.media === 'video' ? 'video ' : '';

  if (call.status === 'live') return { label: `Ongoing ${video}call`, tone: 'live', outgoing };

  if (outgoing) {
    if (call.answered_at) return { label: `Outgoing ${video}call`, duration: call.duration_ms, outgoing };
    if (call.end_reason === 'busy_all') return { label: 'Busy', tone: 'muted', outgoing };
    if (call.end_reason === 'declined_all') return { label: 'Declined', tone: 'muted', outgoing };
    return { label: 'No answer', tone: 'muted', outgoing }; // cancelled or no_answer
  }

  switch (mine?.status) {
    case 'joined':
      return { label: `Incoming ${video}call`, duration: call.duration_ms, outgoing };
    case 'declined':
      return { label: 'Declined', tone: 'muted', outgoing };
    default: // missed or busy
      return { label: `Missed ${video}call`, tone: 'alert', outgoing };
  }
}

Rules this relies on:

  • The caller is started_by and is never in participants.
  • joined means the person was in the call at some point. It wins over a decline on another device.
  • busy means their phone refused the ring during another call. Show it as missed, the way poolse's own missed-call notification does.
  • duration_ms is connected time, from the first answer. It is null when nobody answered.
  • invited only appears while a call is still live: that person's phone is ringing.

Get meId (the poolse id, not your external_id) from useMe().

To show names, resolve started_by.external_id and the other participants' external_ids through your own user directory, the same userResolver you give the SDK. For a group call, the conversation's name is usually the better title.

Paging

next_cursor is null on the last page. Pass it back as cursor for the next page. limit is 1–200 (default 50). A cursor that has been tampered with returns 400 invalid_cursor.

To show only part of the history, add filters:

ParamExample
since / untilsince=2026-10-01T00:00:00Z: ISO 8601, on started_at.
statusended for history only, live for calls in progress.
kindcall or room.

For one conversation's calls (a "calls" section in a chat's details screen), use GET /v1/conversations/:id/calls with the same parameters.

Keep it fresh

A record exists as soon as a call starts, with status: "live". It is completed when the call ends. Refresh the list when the user's user channel reports call:finished:

useEffect(() => {
  if (!me?.id) return;
  const channel = poolse.realtime.user(me.id)._channel();
  const ref = channel.on('call:finished', () => void reload());
  return () => channel.off('call:finished', ref);
}, [me?.id, poolse]);

Also refresh when the app comes back to the foreground. Socket events sent while it was in the background are not replayed.

Call back

Every record has a conversation_id. Calling back means placing a new call in that conversation, with the same media:

// Phones, with @poolse/native
await calls.place(call.conversation_id, { media: call.media, sessionToken: await getToken() });

// Web
await poolse.rooms.ring(call.conversation_id, call.media);

For a row with status: "live", join instead: calls.join(call.id, call.conversation_id, token) on phones, room.join() on the web.

Unread missed calls

poolse does not track whether a user has seen a missed call. To show a badge on the calls tab, store the newest started_at the user has seen on your side, and count the missed rows after it.

From your backend

Your server can read every call in the workspace with an API key: GET /v1/calls, filtered by external_id, conversation_id, kind, status, since and until. Use it for reporting, or to build the tab server-side. The call.finished webhook carries the same record the moment a call ends.

Building a calls tab · Guides · poolse docs