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
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:
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:
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.
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:
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:
// 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:
$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
ttlSecondsshort (default600) 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.
- Read Security and keys before shipping any browser or
streaming integration.