Integration guide

This guide takes the Quickstart setup to production. It covers the parts every integration needs: auth, user identity, attachments, connection handling and the usual mistakes.

How the pieces fit

┌──────────────┐   API key     ┌──────────────┐
│ Your backend │ ────────────▶ │  poolse API  │
│              │ ◀──────────── │              │
│ /api/chat-   │   user + JWT  │              │
│ token        │               │              │
└──────┬───────┘               └──────▲───────┘
       │ JWT (your own request)       │ REST + WebSocket with the JWT
       ▼                              │
┌──────────────────────────────────────┴──┐
│ Your frontend (poolse SDK)              │
└─────────────────────────────────────────┘
  • Your backend holds the API key. It never reaches the browser or the phone.
  • Your backend mints end-user JWTs for signed-in users. A JWT is valid for one hour and only acts as that one user.
  • The SDK uses the JWT for REST and the WebSocket. It asks your getToken for a new one shortly before the old one expires.

1. Backend: the token route

// app/api/chat-token/route.ts (Next.js App Router; the same two calls work in any backend)
const API = 'https://api.poolse.dev';
const KEY = process.env.POOLSE_API_KEY!;

export async function POST(req: Request) {
  const session = await yourAuth(req);
  if (!session) return new Response('unauthorized', { status: 401 });

  const userRes = await fetch(`${API}/v1/users`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ external_id: session.userId }),
  });
  if (!userRes.ok) return new Response('upsert failed', { status: 502 });
  const user = await userRes.json();

  const tokenRes = await fetch(`${API}/v1/users/${user.id}/tokens`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${KEY}` },
  });
  if (!tokenRes.ok) return new Response('mint failed', { status: 502 });
  const { token } = await tokenRes.json();

  return Response.json({ token });
}
  • The key needs the users:manage scope to mint tokens. New dashboard keys have it selected. Without it, the mint answers 403 forbidden.
  • POST /v1/users is an upsert keyed on (workspace, external_id). Calling it on every token request is safe. Only the fields you send are written; fields you leave out keep their stored values. It accepts display_name, avatar_url, custom_data and is_blocked besides external_id. See Users.
  • Mint a new token on every request and don't cache tokens on your server; the SDK caches them on the client.
  • Protect the route with your normal session check. Whoever can call it can act as that user in poolse.

2. Frontend: the provider

npm install @poolse/sdk @poolse/react @poolse/react-ui
'use client';

import '@poolse/react-ui/styles.css';
import { PoolseProvider, type PoolseConfig } from '@poolse/react';
import { ConversationView } from '@poolse/react-ui';

const config: PoolseConfig = {
  getToken: async () => {
    const res = await fetch('/api/chat-token', { method: 'POST', credentials: 'include' });
    if (!res.ok) throw new Error(`chat-token failed: ${res.status}`);
    const { token } = await res.json();
    return token;
  },
  userResolver: async (externalId) => {
    const res = await fetch(`/api/users/${encodeURIComponent(externalId)}`);
    if (!res.ok) return null;
    const u = await res.json();
    return { displayName: u.display_name, avatarUrl: u.avatar_url };
  },
};

export function Chat({ conversationId }: { conversationId: string }) {
  return (
    <PoolseProvider config={config}>
      <ConversationView conversationId={conversationId} />
    </PoolseProvider>
  );
}
  • Mount one provider per signed-in user, near the root. When the user signs out or changes, remount it with a new key (for example the user's id) so the old connection is closed.
  • getToken must use the same HTTP method your route accepts (POST here).
  • apiUrl defaults to https://api.poolse.dev.

3. Identity: names and avatars

The UI shows names and avatars from your data. Everywhere the SDK refers to a user for display (message.sender_external_id, member.external_id, the typing and presence sets) it uses the external_id you gave poolse. userResolver turns that id into a profile:

userResolver: async (externalId) => {
  const u = await db.users.findById(externalId);
  if (!u) return null;
  return { displayName: u.fullName, avatarUrl: u.avatarUrl ?? null };
}
  • Your database doesn't need to store any poolse id.
  • Results are cached in memory, and concurrent lookups for one id share a single call: 50 messages from 5 people call the resolver 5 times.
  • If it returns null or throws, the UI shows the external id and an initials avatar. A thrown error is logged once and cached as null.
  • A labelFor prop on a component overrides the resolver for that component.
  • To refresh a changed profile, call usePoolse().users.invalidate(externalId).

There are also poolse user ids (sender_id, user_id, me.id). The UI props that take a "current user" (currentUserId, meId) and the reactions map use those. See Conventions.

4. Attachments

Files go from the browser or phone straight to poolse's storage with a presigned URL. You don't configure a bucket or CORS, unless you choose to keep files in your own bucket. The composer in ConversationView handles uploads; for your own UI, see Attachment uploads.

Attachments depend on your plan; see Limits & quotas.

5. Connection handling

Web. The socket reconnects on its own after a network drop. Events sent while it was down aren't replayed, so from 3.1 useMessages, useConversations and useLiveCall reload from REST after a reconnect. Show a banner from useRealtimeStatus() if you like; ConversationView already shows one. See Reconnects.

React Native. Call useAppStateLifecycle() once inside the provider. It pauses the socket when the app goes to the background and resumes it when the app returns. Messages that arrive while the app is in the background reach the user as push notifications from poolse, which needs @poolse/native, push credentials on the dashboard and registered devices. See Push notifications.

6. Pitfalls

Next.js: don't transpile the packages

Don't add @poolse/* to transpilePackages. The packages are already compiled, and @poolse/react and @poolse/react-ui start with 'use client'. Use them from client components.

A second 401 ends the session

On a 401 the SDK asks getToken for a new token once and retries. If that also fails, it throws AuthError. A route that returns a cached or expired token ends up here, and so does a blocked user (code is user_blocked; see Blocking a user).

Markdown in messages

@poolse/react-ui renders bodies as GitHub-flavoured Markdown with react-markdown. Raw HTML isn't rendered, and links open in a new tab with rel="noopener noreferrer nofollow". Pass markdown={false} to show plain text.

Idempotency keys are per caller

The SDK sends an Idempotency-Key on every write and reuses it on retries. The server keeps successful responses for 24 hours per workspace, caller, method, path and key. Error responses aren't kept, so fixing a request and sending it again with the same key runs it again.

Invalid ids return 404

A path id that isn't a UUID gets 404 with the normal error body, the same as an id that doesn't exist. Every error, including unknown routes, has the shape { "error": { "code", "message", "doc_url" } }; the SDK turns it into an ApiError. See Errors.

7. Production checklist

  • POOLSE_API_KEY is only in your backend's environment.
  • The key has only the scopes it needs (users:manage for minting).
  • /api/chat-token requires your user session.
  • getToken and the route use the same HTTP method.
  • userResolver returns null for unknown ids.
  • The provider is remounted (new key) when the user changes.
  • React Native: useAppStateLifecycle() is called inside the provider.
  • Phones: push credentials are on the dashboard and devices are registered (poolse.devices.register), and unregistered on sign-out.
  • Webhook receivers verify the signature (see Webhook verification).
  • You have a plan for rotating the API key (see Token rotation).
Integration guide · poolse docs