SDKsPreview · 4 min read

Realtime streaming

Live customer state over Server-Sent Events with stream tokens and the browser SDK subscribeCustomer helper.

Perkamo can push a customer's live state to the browser instead of you polling GET /v1/customer/{user_id}. Wallet balances, level, Perks and Goals arrive over Server-Sent Events as they change, so a points counter or progress bar updates the moment an event is processed.

Streaming and the related SDK helpers are preview. See the Browser SDK for the client-token setup shared by customer reads, events and streams.

How it works

text
GET /v1/client/customer/me/stream    (Content-Type: text/event-stream)

The endpoint authenticates with a stream token, a short-lived client token whose scope is ["stream:read"]. The stream sends the current customer projection immediately, then one message per change. Each message body is the same JSON shape as GET /v1/client/customer/me. The stream closes when the token expires; clients re-subscribe with a fresh token.

Stream tokens are separate from your regular read/write client token on purpose. EventSource cannot set headers, so the token travels in the URL query string. A dedicated stream:read token keeps your customer:read / events:write bearer out of URLs, logs and referrers.

Mint a stream token on your backend

Issue the token from a backend route, after you have verified the user's session, exactly like a normal client token but with the stream:read scope. Self-signing avoids a Perkamo round trip:

ts
import { signClientToken } from "@perkamo/sdk";

// Your backend route, e.g. POST /api/perkamo/stream-token
export async function issueStreamToken(userId: string) {
return signClientToken({
kid: process.env.PERKAMO_SIGNING_KID!,
secret: process.env.PERKAMO_SIGNING_SECRET!, // backend-only
subject: userId,
scope: ["stream:read"],
ttlSeconds: 600,
});
}

Prefer to let Perkamo sign? Use the server SDK with your server API key:

ts
const { token } = await perkamo.createClientToken(userId, {
scope: ["stream:read"],
});

The requested scope is clamped to the signing key policy at verification, so a key that does not allow stream:read cannot open a stream.

Subscribe from the browser

Pass a getStreamToken provider alongside getToken, then call subscribeCustomer. The browser SDK never places a bearer token in the stream URL. It requires getStreamToken and throws if it is missing.

ts
import { createPerkamoBrowserClient } from "@perkamo/browser";

const client = createPerkamoBrowserClient({
getToken: () =>
fetch("/api/perkamo/token", { method: "POST" })
.then((response) => response.json())
.then((body) => body.token),
getStreamToken: () =>
fetch("/api/perkamo/stream-token", { method: "POST" })
.then((response) => response.json())
.then((body) => body.token),
});

const subscription = client.subscribeCustomer(
(customer) => {
document.querySelector("#points").textContent = String(
customer.wallets.points ?? 0,
);
},
(error) => console.error("Perkamo stream error", error),
);

// Later, when the widget unmounts:
subscription.close();

subscribeCustomer(onCustomer, onError?) returns { close() }. Call close() when the component unmounts so the EventSource does not leak. Handle onError to re-subscribe after the token expires or the connection drops.

React

With the React SDK, add a streamTokenEndpoint (or getStreamToken) to the provider and use the stream hook:

tsx
import { PerkamoProvider, usePerkamoCustomerStream } from "@perkamo/react";

function LivePoints() {
const { customer, error } = usePerkamoCustomerStream();
if (error || !customer) return null;
return <p>{customer.wallets.points ?? 0} points</p>;
}

<PerkamoProvider
tokenEndpoint="/auth/perkamo/token"
streamTokenEndpoint="/auth/perkamo/stream-token"
>
<LivePoints />
</PerkamoProvider>;

The hook subscribes on mount and closes the stream on unmount.

React Native

React Native has no EventSource, so the React Native SDK implements both subscribeCustomer and the usePerkamoCustomerStream() hook by polling the customer endpoint with the regular client token. No stream token is required; configure the cadence with subscribePollIntervalMs.

Next.js

createPerkamoNextBrowserClient reads its stream token from /api/perkamo/stream-token by default (override with streamTokenEndpoint). Mount that route with a stream:read scope:

ts
// app/api/perkamo/stream-token/route.ts
import { createPerkamoClientTokenRoute } from "@perkamo/nextjs/server";
import { auth } from "@/auth";

export const POST = createPerkamoClientTokenRoute({
kid: process.env.PERKAMO_SIGNING_KID,
signingSecret: process.env.PERKAMO_SIGNING_SECRET,
getUserId: async () => (await auth())?.user?.id ?? null,
scope: ["stream:read"],
});

The usePerkamoCustomerJson hook then reflects streamed updates without extra wiring. See the Next.js SDK for the full client setup.

Symfony

The bundle exposes a dedicated stream-token issuer and route so frontends never touch signing secrets:

php
$streamToken = $tokenIssuer->createStreamToken($userId);

ApiBrowserTokenIssuer::createStreamToken() and the mounted stream-token controller mirror the regular browser token flow. See the Symfony bundle.

Operational notes

  • A stream never outlives its token. Keep ttlSeconds short (default 600) and

re-subscribe on error; the SDK surfaces expiry through onError.

  • Streaming reflects the same projection as the REST reads. Treat it as a live

view, not a separate source of truth.

streaming integration.