SDK TypeScript
One package for your backend proxy and your app: timeouts, fallback, consent gate, offline queue.
patterna-sdk is the TypeScript client. Use it in two places: on your backend, which holds the keys, and in your browser or Expo app, which talks to your backend proxy and never to Patterna with a key.
It ships ESM and CJS with types, has zero runtime dependencies and needs only fetch, AbortController, setTimeout and, for signing, WebCrypto. It runs on Node 22+, Bun, Deno, edge runtimes and React Native.
Install#
npm install patterna-sdk@0.4.1patterna-sdk 0.4.1 is on npm (unscoped: there is no @patterna/sdk), published from the public repository payam-h/patterna-sdk (MIT), subdirectory typescript/, tag v0.4.1. Pin the version. It works with engine 0.3.1 (engine_version on /health).
The client#
import { PatternaClient } from "patterna-sdk";
const patterna = new PatternaClient({
baseUrl: "https://api.patterna.nl/v1",
keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
});| Method | Key | Budget | Retried |
|---|---|---|---|
recommend(req) | serve | 150 ms | never: each call logs an impression |
reportInteractions(requestId, interactions) | serve | 5 s | once |
ingestEvents(events) | ingest | 5 s | once (idempotent on event_id) |
upsertItems(items) | ingest | 5 s | once |
setItemStatus(kind, id, { status, published_at }) | ingest | 5 s | once |
setItemTags(kind, id, tags) | ingest | 5 s | once |
setUserMode(userId, mode) | admin if set, else ingest | 5 s | once |
setUserProfile(userId, profile) | admin if set, else ingest | 5 s | once |
deleteUser(userId) | admin if set, else ingest | 5 s | once |
Budgets are per attempt. Change them per client with timeouts: { recommendMs, ingestMs } or per call with { timeoutMs, signal }. The single retry fires after 100 ms on 5xx and network errors only. Timeouts are not retried: the budget is already spent.
The engine accepts the ingest key for setUserMode, setUserProfile and deleteUser, so a backend needs only a serve key (pk_s_…) and an ingest key (pk_i_…). Never configure the admin key (pk_a_…) in a request path.
Errors#
Every failure is a PatternaError with code, op, status, requestId, details and a transient getter. The subclasses:
| Class | When |
|---|---|
ApiError | the API answered with an error; code is the API's, e.g. no_candidates, surface_off, mode_conflict, pii_rejected. All codes: Error codes |
TimeoutError | the budget ran out |
NetworkError | no answer |
ConsentError | a consent rule was broken; nothing was sent |
ValidationError | bad input, such as an email used as a user id; nothing was sent |
import { ApiError } from "patterna-sdk";
try {
await patterna.ingestEvents(batch);
} catch (err) {
if (err instanceof ApiError) log.warn({ op: err.op, code: err.code, request_id: err.requestId });
else throw err;
}Consent gate#
The SDK refuses to send a request that breaks the user's consent mode.
recommendthrowsConsentError(consent_required) whenmodeis missing or unknown.- A
non_profiledrequest must carry asession_idand nouser_id. The types forbid it, and at runtime the SDK throwsConsentError(consent_violation). - A user id must be the random per-tenant id you mint. Emails and identity-provider subjects (
auth0|...) are refused withValidationErroreverywhere a user id goes. The id never appears in the error message. - Event
contextaccepts onlylocale,app_version,platform,hour_bucket,weekday,region,subscription_tier,tenure_days.
See Consent modes.
Fallback#
withFallback runs the Patterna call and switches to your own list on failure.
import { withFallback } from "patterna-sdk";
const r = await withFallback(
() => patterna.recommend({ surface: "home_articles", mode, user_id, context: { locale: "nl" } }),
() => newestArticleIds(), // your current query
{ onFallback: (e) => metrics.inc("patterna_fallback", { code: e.code }) },
);
const ids = r.source === "patterna" ? r.value.items.map((i) => i.item_id) : r.value;- It falls back on any SDK error except
ConsentError,ValidationError,pii_rejectedandmode_conflict. Those are bugs in your code, and a consent bug is a privacy bug, so they are rethrown. - An
ApiErroralso falls back. Check itscodeinonFallback:surface_off(409) andno_candidates(422) are expected while a surface is off or empty;bad_request(400, also for any unknown JSON field),unauthorized/wrong_scope(401), andunknown_surface(404) are bugs to alert on, even though the user sees your list. { on: "timeout" }narrows the fallback to timeouts.- Report interactions only when
r.source === "patterna". A fallback list has no request id. - Report each item with its
kind(copy it from the slate item) and its 0-basedposition. The engine takeskindand the served position from the logged slate anyway and refuses item ids that were not in it (422 unknown_item). - A slate with
shown: false(fewer items than the surface'smin_slots) means: hide the block. Do not callabandonfor it; the engine has already logged it as not shown (abandon answers409 not_shown). Such a slate carries no items and stays in its arm counted as served with zero taps. withFallbackdoes not report abandonment. On a backend, preferpatterna.recommendWithFallback(req, fallback), which does (see below).
Backend proxy#
Your backend authenticates the user, swaps the account id for the Patterna id it minted, applies the user's consent and forwards. The proxy paths mirror Patterna's, so the same SDK works on both sides.
import { Hono } from "hono";
import { PatternaClient, withFallback, ValidationError, ConsentError, type Event } from "patterna-sdk";
const patterna = new PatternaClient({
baseUrl: process.env.PATTERNA_URL!,
keys: { serve: process.env.PATTERNA_SERVE_KEY, ingest: process.env.PATTERNA_INGEST_KEY },
});
const app = new Hono<{ Variables: { account: Account } }>();
app.use("/patterna/*", requireSession); // sets c.var.account
app.post("/patterna/recommendations", async (c) => {
const body = await c.req.json();
const { mode, patternaId } = await consentFor(c.var.account); // your table; missing = non_profiled
const req =
mode === "non_profiled"
? { surface: body.surface, mode, session_id: body.session_id, context: body.context }
: { surface: body.surface, mode, user_id: patternaId, context: body.context };
// fallbackSlate(): your current list as a Slate with `fallback: true` and an empty request_id,
// so the app renders one shape and skips reporting interactions for it.
const r = await withFallback(() => patterna.recommend(req), () => fallbackSlate(body.surface));
return c.json(r.value);
});
app.post("/patterna/events", async (c) => {
const { events } = (await c.req.json()) as { events: Event[] };
const { mode, patternaId } = await consentFor(c.var.account);
// Never trust identity from the client: set it here.
const owned = events.map((e) =>
mode === "non_profiled" ? { ...e, user_id: undefined, source: "backend" as const }
: { ...e, user_id: patternaId, session_id: undefined, source: "backend" as const });
try {
return c.json(await patterna.ingestEvents(owned), 202);
} catch (err) {
if (err instanceof ValidationError || err instanceof ConsentError) return c.json({ code: err.code }, 400);
throw err;
}
});Account, requireSession, consentFor and fallbackSlate are yours. Set PATTERNA_URL to https://api.patterna.nl/v1.
Expo app#
The app talks to your proxy, authenticated as the signed-in user. It holds no Patterna key.
import AsyncStorage from "@react-native-async-storage/async-storage";
import "react-native-get-random-values"; // crypto.getRandomValues for newSessionId on Hermes
import { AppState } from "react-native";
import { EventQueue, PatternaClient, newSessionId, withFallback } from "patterna-sdk";
const patterna = new PatternaClient({
baseUrl: `${config.apiUrl}/patterna`,
headers: async () => ({ authorization: `Bearer ${(await getValidTokens())?.accessToken ?? ""}` }),
timeouts: { recommendMs: 400 }, // a phone round trip to your proxy, which keeps its own 150 ms
});
export const outbox = new EventQueue({
client: patterna,
storage: AsyncStorage, // persist / restore across app launches
maxSize: 500,
onDrop: (reason, n) => track("patterna_events_dropped", { reason, n }),
});
let sessionId = newSessionId(); // rotate per launch
export async function onConsentLoaded(mode: "personalized" | "cohort" | "non_profiled") {
await outbox.setMode(mode); // nothing is queued before this; non_profiled purges named events
}
AppState.addEventListener("change", (s) => {
if (s === "active") sessionId = newSessionId();
else void outbox.flush(); // send before the OS suspends the app
});
export async function homeArticles(fallbackIds: string[]) {
return withFallback(
() => patterna.recommend({ surface: "home_articles", mode: "non_profiled", session_id: sessionId, context: { locale: "nl", platform: "ios" } }),
() => fallbackIds,
);
}
export function seen(requestId: string, itemId: string, position: number) {
return outbox.enqueue({
event_id: `${requestId}:${itemId}:seen`, // idempotency key: one seen per slot
type: "rec_seen", occurred_at: new Date().toISOString(), source: "sdk",
session_id: sessionId, request_id: requestId, surface: "home_articles",
props: { item_id: itemId, kind: "document", position }, // kind from the slate item; position from 0
});
}Only report when the user allowed usage analytics. Otherwise skip seen and pass context.measured: false on the request, so the slate is left out of the ledger: see Consent modes.
The proxy sets the real identity. So the app sends session_id only and stays correct whatever the user's mode is.
Offline queue#
EventQueue is a bounded outbox for events sent from the app.
- Bounded.
maxSizecaps it. When it overflows,onDrop(reason, n)tells you how many events went. - Persistent. Pass an AsyncStorage-shaped
storageand the queue survives app restarts. Restoring is idempotent. - Consent-aware. Nothing is queued until
setModeis called. Switching tonon_profiledpurges events that name a user. - Idempotent. Events keep their
event_id, so a resend after a crash is counted once. - Call
flush()when the app goes to the background.
Signing#
import { signEvent } from "patterna-sdk";
// The key is the pss_ secret string.
const signed = await signEvent(event, process.env.PATTERNA_SIGNING_SECRET!);The signature is lowercase hex HMAC-SHA256 over the canonical JSON of the event without signature: keys sorted, no whitespace, undefined dropped, U+2028 and U+2029 escaped. The HMAC key is the pss_ secret string's UTF-8 bytes. Since engine 0.2 secrets are pss_ strings; a secret from before that (64 hex characters, no prefix) no longer verifies, so rotate it once. Events from your proxy with an ingest key are trusted without a signature anyway, so most backends never sign. Sign on a backend only. On React Native without crypto.subtle, pass a SubtleCrypto implementation as the third argument. Test vector: Backend API.
Deadline, abandonment and the rest of the client#
The same features as the Go SDK, in TypeScript shape:
recommendsendsX-Deadline-Ms(120 ms by default, below the 150 ms budget) and anX-Request-Idit minted.recommendWithFallback(req, fallback)reports a timeout, 5xx or network failure withabandon(requestId, reason)in the background, so the engine flags the slateclient_abandoned; it stays in its arm and counts in the fallback rate. Callabandonyourself when you drop a slate that came back shown. See Timeouts and the ledger.recommendMany([{ request, fallback }, …])for several surfaces on one screen, under one deadline.assignmentUnit(secret, userId)forassignment_unit(64 hex characters,non_profiledonly),context.measured, the interaction fieldblock, and the call option{ tester: true }(X-Patterna-Tester: 1).getItem(kind, id)reads one item with itsattributes,tags,statusandenrichment(ingest key;item_not_foundwhen there is none under that id and kind);listItems,itemsChangedSinceanddeleteItemfor catalog reconciliation.- Admin methods on the same client, with an admin key:
listSurfaces,setSurfaceStatus,setSurfaceTesters,createFlow,planFlow,applyFlow,activateFlow,rollbackFlow,startExperiment,endExperiment,ledger,exportLedger,createSigningSecret,rotateSigningSecret. ThepatternaCLI ships with the Go SDK.
Control lists, communities and the context fields#
SDK 0.4.1 types context.control_item_ids (your own editorial list for the page, served to the control arm) and context.member_of (communities the user joined, left out in every mode), and reads fill_count, fill, control_source and shown from the slate. See Backend API.
The engine reads three more context fields: weekday (0 = Monday, from the device clock), subscription_tier and tenure_days. SDK 0.4.1 types them, together with the catalog attributes the engine reads, builders for the new events, the flow filters and generators of engine 0.3.1, and the reason key recommended. What each field does: What the engine uses, and what it refuses.
Left out on purpose#
- A circuit breaker. Backends that need one use the Go SDK; in an app,
withFallbackand the per-call budget are enough.