@tesseraloyalty/sdk
v0.3.0
Published
Official TypeScript SDK for the Tessera loyalty platform operations API (/v1).
Readme
@tesseraloyalty/sdk
Official TypeScript / JavaScript SDK for the Tessera loyalty platform. It wraps the public operations API (/v1) and owns the error-prone plumbing — bearer auth, HMAC request signing, idempotency keys, cursor pagination, retries with backoff, and a typed error taxonomy — so your backend can drive the whole member journey (browse → identify → earn → read → redeem) in a handful of lines.
Runs anywhere the Web fetch standard exists: Node 20+, edge / Cloudflare Workers, Deno, and Bun. Ships ESM + CommonJS with bundled types, and has zero runtime dependencies.
Status — publishing pending (TER-95).
@tesseraloyalty/sdkis0.x; the release pipeline is prepared but the package is not on npm yet, so the install line below is forthcoming. SDKv0.xtargets Tessera operations APIv1(exported asAPI_VERSION).
Install
npm i @tesseraloyalty/sdk
# or: pnpm add @tesseraloyalty/sdk / yarn add @tesseraloyalty/sdk / bun add @tesseraloyalty/sdkimport { TesseraClient } from '@tesseraloyalty/sdk';Constructing the client
TesseraClient takes exactly one API key plus optional transport and channel settings. Secret-key operations must run server-side (backend / BFF / edge function) — never ship a secret key to a browser.
const tessera = new TesseraClient({
secretKey: process.env.TESSERA_SECRET_KEY, // unlocks every operation (server-side only)
signingSecret: process.env.TESSERA_SIGNING_SECRET, // SEPARATE connector HMAC secret, for events.ingest
channel: 'shopify', // the channel bound to every member operation
});| Option | Type | Default | Purpose |
| --------------- | -------------------------- | ------------------------------ | ------- |
| secretKey | string | — | The bearer key that unlocks every operation. Provide exactly one of secretKey / publishableKey. |
| publishableKey| string | — | A catalogue-only bearer key — the only call it may make is rewards.list(). Any secret-scope call fails fast, pre-network, with PublishableKeyForbiddenError. |
| signingSecret | string | — | The per-tenant connector HMAC secret (whsec_…) used to sign events.ingest. It is a separate credential from the API key and is only needed to ingest events. |
| channel | Ch | 'api' | The channel bound to every customer-scoped operation (see Channel binding). Must be a member of channels when that allowlist is set. |
| channels | readonly Ch[] | — | An optional channel allowlist. Enforced at runtime (out-of-list → pre-network throw) and, via as const, at compile time (typos fail to compile). |
| environment | 'production' \| 'sandbox' | 'production' | The hosted environment. Picks the base URL from a preset (see Environments). Ignored when baseUrl is set. |
| baseUrl | string | environment preset | The /v1 base URL. Overrides environment — use for local docker / CI / self-hosted. |
| timeoutMs | number | 30000 | Per-request timeout. A timeout is terminal (not retried). |
| maxRetries | number | 2 | Retries on retriable failures (network / 5xx / 429). |
| fetch | FetchLike | globalThis.fetch | A custom fetch implementation (tests, proxies, older runtimes). |
The keys map to the two resource scopes: a publishableKey client may only call rewards.list(); everything else (customers, events, member(...) reads/redeem, redemptions) requires a secretKey.
Environments
The SDK ships base-URL presets for the two hosted stages. Pick one with environment (default production); an explicit baseUrl overrides it for local / CI / self-hosted:
| environment | Base URL |
| ------------- | --------------------------------- |
| production | https://api.usetessera.io/v1 |
| sandbox | https://sandbox.usetessera.io/v1 |
const tessera = new TesseraClient({ secretKey: process.env.TESSERA_SECRET_KEY, environment: 'sandbox' });
// local dev / CI — baseUrl wins, environment is ignored:
const local = new TesseraClient({ secretKey: 'sk_test_local', baseUrl: 'http://localhost:3100/v1' });Key/host guardrail. When you use a preset environment (i.e. you did not pass baseUrl), the SDK checks your key's prefix against the target: sk_test_/pk_test_ keys belong to sandbox, sk_live_/pk_live_ keys to production. A mismatch — the classic "test key against the prod host" slip — throws a clear config_error before any network call. Keys without a recognized prefix are not checked, and passing an explicit baseUrl skips the check entirely (local keys need not follow the convention).
Quickstart — the full journey (server-side)
A complete earn → read → redeem flow with a secret key. rewards.list() → customers.upsert() → events.ingest() (auto-signed) → member.balance() / .status() / .history() → member.redeem() → finalize().
import { TesseraClient } from '@tesseraloyalty/sdk';
const tessera = new TesseraClient({
secretKey: process.env.TESSERA_SECRET_KEY,
signingSecret: process.env.TESSERA_SIGNING_SECRET, // required for events.ingest
channel: 'shopify',
});
// 1. Browse the public reward catalogue (allowed with a publishable OR a secret key).
const { pointsLabel, data: rewards } = await tessera.rewards.list();
// 2. Identify / enroll the member — create-or-update, keyed on (channel, externalId).
const customer = await tessera.customers.upsert({
externalId: 'cust_5512',
email: '[email protected]',
name: 'Ada Lovelace',
});
console.log(customer.created ? 'enrolled' : 'updated', customer.customerUuid);
// 3. Ingest a purchase. The SDK HMAC-signs the EXACT bytes it sends (needs signingSecret);
// earn is processed asynchronously server-side, so `enqueued` means the earn job was queued.
const event = await tessera.events.ingest({
type: 'purchase',
channel: 'shopify',
externalId: 'cust_5512',
sourceRef: 'ord_9001',
idempotencyKey: 'shopify:ord_9001-purchase', // namespace the channel into the key
occurredAt: new Date(),
trigger: 'orders/paid',
amount: { netOfTaxShipping: 4200, currency: 'USD' }, // minor units
});
console.log(event.status); // 'accepted' (first sighting) | 'duplicate' (deduped redelivery)
// 4. Open a member handle on the bound channel and read their standing.
const member = tessera.member('cust_5512');
const balance = await member.balance();
console.log(`${balance.available} ${balance.pointsLabel} available`);
const status = await member.status();
console.log('tier:', status.currentTier?.name ?? 'none');
// 5. Redeem: place a hold (auto Idempotency-Key), then finalize it against the order that
// consumed it — or release it if the cart is abandoned.
const redemption = await member.redeem({
optionId: rewards[0].id,
cartSubtotalCents: 4200,
});
// …order is placed…
await redemption.finalize({ sourceChannel: 'shopify', sourceRef: 'ord_9001' });
// …or, on an abandoned cart: await redemption.release();Reading history — the async iterator
member.history() returns a HistoryPager that is both awaitable (resolves the first HistoryPage — .data + .page) and async-iterable (auto-follows the cursor across every page):
// Iterate every entry across all pages — the pager follows the cursor for you.
for await (const entry of member.history()) {
console.log(entry.type, entry.currency, entry.amount, entry.sourceRef);
}
// …or just the first page, with filters:
const page = await member.history({ limit: 50, types: ['earn', 'burn'], since: '2026-01-01' });
console.log(page.data.length, 'entries; more?', page.page.hasMore);The other member reads — member.get() (pseudonymous profile) and member.balance() / member.status() — resolve to a single object.
Channel binding
A member operation never takes a per-call channel — it is bound once on the client, so a 'shopify'-here / 'shoify'-there typo can't silently split a customer's identity and ledger. Three layers enforce this:
- Bound channel — set once via
channel(default'api');withChannel(x)derives a channel-scoped clone that shares the same transport, auth, and signing secret for the rare legitimate multi-channel case. - Runtime allowlist — when
channelsis provided, the bound channel, everywithChanneltarget, and every resolved channel must be a member, or the SDK throws pre-network naming the known channels. - Compile-time union — pass
channels: [...] as constand the client is generic over that literal union, so a typo is a type error, not a runtime surprise.
const tessera = new TesseraClient({
secretKey: process.env.TESSERA_SECRET_KEY,
channels: ['shopify', 'pos', 'api'] as const, // Ch = 'shopify' | 'pos' | 'api'
channel: 'shopify',
});
const pos = tessera.withChannel('pos'); // ok — clone bound to 'pos'
tessera.withChannel('shoify'); // ✗ compile error AND a pre-network throw
// Per-handle override, still validated against the allowlist:
const m = tessera.member('cust_5512', { channel: 'pos' });Errors
Every failure is a TesseraError (or a subclass) carrying the stable, snake_case code from the API envelope plus status, requestId (the X-Request-Id, for support), and structured details. Catch by type — never string-match code:
import {
InsufficientBalanceError,
TierGatedError,
HoldAlreadyReleasedError,
PublishableKeyForbiddenError,
TesseraError,
} from '@tesseraloyalty/sdk';
try {
const r = await member.redeem({ optionId, cartSubtotalCents: 4200 });
await r.finalize({ sourceChannel: 'shopify', sourceRef: 'ord_9001' });
} catch (err) {
if (err instanceof InsufficientBalanceError) {
console.log('short by', err.shortfall); // number | undefined, from details.shortfall
} else if (err instanceof TierGatedError) {
// option requires a higher tier than the member holds
} else if (err instanceof HoldAlreadyReleasedError) {
// finalizing a hold that was already released (terminal)
} else if (err instanceof TesseraError) {
// any other code — including one this SDK version doesn't yet know (see below)
console.log(err.code, err.status, err.requestId);
}
}A new server code never crashes an old SDK: an unrecognized code resolves to the base TesseraError (with its code preserved), never a throw. The full code → subclass map is exported as CODE_TO_ERROR, and errorFromEnvelope is the factory that builds them.
| Error class | Wire code | Extra field |
| ------------------------------ | -------------------------------------------------- | ----------- |
| ValidationError | invalid_request | |
| UnauthorizedError | unauthorized | |
| SignatureInvalidError | signature_invalid | |
| ReplayDetectedError | replay_detected | |
| ForbiddenError | forbidden | |
| TierGatedError | tier_gated | |
| PublishableKeyForbiddenError | publishable_key_forbidden | |
| NotFoundError | not_found | |
| CustomerNotFoundError | customer_not_found | |
| RedemptionNotFoundError | redemption_not_found | |
| ConflictError | conflict | |
| IdempotencyConflictError | idempotency_key_conflict / idempotency_key_reuse | |
| VersionConflictError | version_conflict | |
| CapReachedError | cap_reached | |
| NotStackableError | not_stackable | |
| HoldAlreadyReleasedError | hold_already_released | |
| CustomerErasedError | customer_erased | |
| PayloadTooLargeError | payload_too_large | |
| InsufficientBalanceError | insufficient_balance | .shortfall: number \| undefined |
| OptionNotEligibleError | option_not_eligible | |
| InvalidPayloadError | invalid_payload | |
| UnsupportedEventTypeError | unsupported_event_type | |
| ProfileValidationError | profile_validation_error | .field: string \| undefined |
| RateLimitedError | rate_limited | |
| InternalError | internal_error | |
| NotImplementedError | not_implemented | |
Pre-network failures (bad config, an out-of-allowlist channel, a missing signing secret) throw a base TesseraError with an SDK-local code (config_error) and no status.
Idempotency & retries
- Redemptions auto-key.
POST /v1/redemptionsrequires anIdempotency-Key; when you don't pass one,member.redeem(...)/redemptions.place(...)auto-generate a UUID and hold it constant across that call's own retries, so a retried place never double-charges the hold. Pass{ idempotencyKey }to make a place stable across your retries too. customers.upsertaccepts an optionalidempotencyKeyfor a safe create-or-update retry.events.ingestdedups on theidempotencyKeyinside the signed body (namespace the channel in, e.g.shopify:ord_9001-purchase). TheIdempotency-Keyheader is optional and, if sent, must echo that body value.- Retries. Network errors,
5xx, and429are retried up tomaxRetries(default 2) with exponential backoff (200 ms, doubling, capped at 20 s), honoring aRetry-Afterheader when present. A per-request timeout (timeoutMs, default 30 s) is terminal — it means the call blew its budget and is not retried.
Event signing (advanced)
events.ingest signs for you. If you need to sign a request body yourself (e.g. a custom transport), the low-level signer and its header constants are exported:
import { signEventBody, SIGNATURE_HEADER, TIMESTAMP_HEADER, NONCE_HEADER } from '@tesseraloyalty/sdk';
const rawBody = JSON.stringify(payload); // sign and send the SAME bytes
const signed = await signEventBody(rawBody, signingSecret); // HMAC-SHA256, `v1=<hex>`
// signed.headers → { 'X-Loyalty-Signature', 'X-Loyalty-Timestamp', 'X-Loyalty-Nonce' }Re-serializing the body after signing invalidates the signature — sign and transmit the exact same string.
What this is not
@tesseraloyalty/sdk is a loyalty-operations client only. It serves customers, events, balances/reads, and redemptions for an already-provisioned tenant under a per-tenant API key. It deliberately does not cover:
- merchant signup or tenant provisioning;
- program configuration — earn rules, tiers, reward options, points label, branding. Those are managed in the merchant console, not the SDK.
If you're looking for a method to change how points are earned or what rewards exist, it isn't here by design — configure the program in the console; use this SDK to run it.
License
MIT — see LICENSE.
