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 achievements 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 subscribeCustomer — and the same 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.