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
getTokenfor 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:managescope to mint tokens. New dashboard keys have it selected. Without it, the mint answers403 forbidden. POST /v1/usersis 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 acceptsdisplay_name,avatar_url,custom_dataandis_blockedbesidesexternal_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. getTokenmust use the same HTTP method your route accepts (POSThere).apiUrldefaults tohttps://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
nullor throws, the UI shows the external id and an initials avatar. A thrown error is logged once and cached asnull. - A
labelForprop 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_KEYis only in your backend's environment. - The key has only the scopes it needs (
users:managefor minting). -
/api/chat-tokenrequires your user session. -
getTokenand the route use the same HTTP method. -
userResolverreturnsnullfor 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).