No description
  • TypeScript 100%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Egor Gorbunov 7039304f7b Release 0.5.0: the SDK's own version (#136)
package.json and SDK_VERSION to 0.5.0, CHANGELOG section dated. Works with
platform (TKL) >=0.5.0, <0.6.0. Published to npm by the maintainer; the repo
tag v0.5.0 follows the publish (RELEASE.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 01:12:39 +03:00
scripts docs: drop ADR references from comments and docs 2026-09-15 04:26:38 +03:00
src Release 0.5.0: the SDK's own version (#136) 2026-09-29 01:12:39 +03:00
.gitignore feat: initial public release of the Cusfront SDK 2026-06-21 17:13:31 +03:00
CHANGELOG.md Release 0.5.0: the SDK's own version (#136) 2026-09-29 01:12:39 +03:00
LICENSE feat: initial public release of the Cusfront SDK 2026-06-21 17:13:31 +03:00
package-lock.json 0.3.21: cards are issued for the product price; starting_balance on products 2026-09-21 14:41:42 +03:00
package.json Release 0.5.0: the SDK's own version (#136) 2026-09-29 01:12:39 +03:00
pnpm-workspace.yaml 0.3.7: two-sided card designs, card_otp inbox item (#89) 2026-09-16 16:30:09 +03:00
README.md client.ready(): platform compatibility; the SDK has its own version (#136) 2026-09-29 00:32:13 +03:00
RELEASE.md client.ready(): platform compatibility; the SDK has its own version (#136) 2026-09-29 00:32:13 +03:00
tsconfig.json feat: initial public release of the Cusfront SDK 2026-06-21 17:13:31 +03:00
tsup.config.ts Analytics: one event layer for PostHog and Metrica (#118) 2026-09-28 21:37:32 +03:00
vitest.config.ts feat: initial public release of the Cusfront SDK 2026-06-21 17:13:31 +03:00

@takeal/cusfront-sdk

Typed TypeScript client for the Takeal end-user API: sign-up and login, wallet, deposits, cards, sessions, the notification inbox, devices and webhook signature verification.

The SDK powers any end-user client app on top of a Takeal deployment: a PWA, a Capacitor-wrapped mobile app, a Telegram Mini App, or anything else that talks to the /auth/* + /me/* surface. One client, runtime brand swap, zero runtime deps in the core.

Versioning: the SDK version matches the Takeal platform release it was checked against (SDK 0.2.8 goes with API release v0.2.8). Until 1.0, a minor version can break things; read the changelog before upgrading.

Install

pnpm add @takeal/cusfront-sdk
# optional peer dep for runtime validation
pnpm add zod

Native fetch is required. Node ≥ 18, modern browsers, Bun, Deno, and React Native ≥ 0.74 (Hermes) all ship it out of the box. For older runtimes inject a polyfill via createClient({ fetch }).

Quick start

import { createClient } from "@takeal/cusfront-sdk";

const client = createClient({
  baseUrl: "https://api.your-deployment.example.com",
  brand: {
    name: "Your Brand",
    logoUrl: "/logo.svg",
    primaryColor: "#0047AB",
  },
});

const result = await client.auth.login({
  email: "user@example.com",
  password: "secret",
});

if (result.stage === "jwt") {
  // Authenticated — JWT auto-stored.
  const me = await client.auth.me();
  console.log("hello", me.email);
} else if (result.stage === "totp_required") {
  // Step-up required. Prompt the user for their TOTP code,
  // then call client.auth.verifyTotp({ challenge_token, code }).
} else if (result.stage === "totp_setup_required") {
  // The account has to enrol an authenticator first. Show result.otpauth_url
  // as a QR code, then call client.auth.completeTotpSetup({ challenge_token, code })
  // and show the backup_codes it returns. They are never sent again.
}

New users sign up with register, which also signs them in:

await client.auth.register({
  email: "user@example.com",
  password: "a-long-Passw0rd",
  wallet_currency: "EUR", // optional; must be offered by the deployment
});

Choosing a card

The API publishes a catalog of the cards a user can order, and the client walks it from the list to the issued card:

// 1. The catalog: what the user can order, in the order the operator
//    arranged the storefront. `is_default` marks the product issued when
//    the user does not choose; it can sit anywhere in the list.
const products = await client.cards.listProducts();
// Before sign-in (a landing page), the same list at list prices:
// const products = await client.cards.catalog();

for (const p of products) {
  // The tile: the operator's picture when there is one, the card design
  // otherwise. `image_url` is absolute for uploaded designs and a path
  // relative to the API for the deployment default; `assetUrl` handles both.
  const img = client.assetUrl(p.tile_image_url ?? p.design.image_url);

  // Price after the user's subscription discount; `null` means free.
  // `price.currency` is what the user pays in (their wallet currency once
  // signed in), which need not be the currency the card is held in.
  const price = p.price
    ? p.price.discount_bps > 0
      ? `${p.price.amount} ${p.price.currency} (was ${p.price.base_amount}, ${p.price.discount_bps / 100}% off)`
      : `${p.price.amount} ${p.price.currency}`
    : "free";

  // The product card: name, one-line tagline, selling points, description.
  console.log(p.name, p.tagline, p.benefits, p.description, p.card_type, p.currency, price, img);
}

// 2. Issue from a product. `type` and `issuer_slug` are no longer needed:
//    the product settles both. The user picks a name and, when the product
//    offers a choice, one of `p.designs`.
const product = products.find((p) => p.is_default) ?? products[0];
const card = await client.cards.create({
  productId: product.id,
  name: "Groceries",
  designId: product.designs[0]?.id ?? null, // null = the product's default design
  initial_amount: "50.00",
  currency: product.currency ?? "USD",
  idempotencyKey: crypto.randomUUID(),
  customer: { email: "user@example.com", first_name: "Alice", last_name: "Tester" },
});

// The card carries what was chosen.
card.name;                              // "Groceries"
client.assetUrl(card.design.image_url); // ready for <img src>
card.product;                           // { id, name } or null for older cards

// 3. Rename or change the design later.
await client.cards.update(card.id, { name: "Coffee" });
await client.cards.update(card.id, { designId: null });

cards.getProduct(id) fetches one entry. product.features is a free-form object the operator fills in (payment scheme, wallet support, load limits and so on), so read the keys you need and ignore the rest. Names are 1 to 40 characters; a design outside the product's designs and an unknown or hidden product are rejected with a 400 whose code says which check failed (invalid_name, design_not_allowed, unknown_product, product_mismatch).

Calls without productId keep working as before: type is then required and the deployment picks the product.

resolveAssetUrl(baseUrl, url) is the same helper as a standalone function, exported from the package root, for code that has no client at hand.

What a card costs

A card is issued for its product's price, nothing else is taken from the wallet. Some providers insist on a starting balance; when the product has one it is shown as starting_balance and is already part of the price. Everything beyond that goes onto the card with topUp().

Topping up a card

Money moves from the wallet onto the user's own card in one call. Read the terms first: they say whether the card can be topped up now, what the wallet holds and the product's floor and cap for one top-up.

const terms = await client.cards.topUpTerms(card.id);
if (!terms.available) showError(terms.reason);

const { card: updated, card_balance, wallet } = await client.cards.topUp(card.id, {
  amount: "15.00",                 // in terms.currency
  idempotencyKey: crypto.randomUUID(),
});

Every error is a sentence the user can read (error.message): not enough in the wallet (402), amount outside the floor or cap (400), card frozen or still being issued (409), provider declined or unreachable (502/503, the wallet is untouched).

Topping up a card directly

Some deployments let a top-up go straight onto a card, past the wallet, and some have no wallet at all. Ask the API instead of hard-coding it:

const options = await client.fundingOptions.list();
// [{ slug, mode: "wallet" | "spool" | "mixed", targets: ["wallet", "card"],
//    methods: ["card", "crypto"], issue_card: true }]
const direct = options.find((o) => o.targets.includes("card"));

Top up an existing card with card_id, or issue a new card with its first top-up with issue_card (the fields of cards.create). The card's price comes out of the top-up and the rest is its starting balance.

const deposit = await client.deposits.initiate({
  amount: "25.00",
  currency: "USD",
  method: "card",
  funder_slug: direct.slug,
  card_id: card.id,                 // or: issue_card: { productId, name }
  idempotencyKey: crypto.randomUUID(),
});

Paying in another currency than the card's needs a quote in the card's currency first: deposits.quote({ amount, currency, card_id }), or target_currency for a card that doesn't exist yet.

The deposit comes back with target: "card" and a card_load_status. It is pending until the card provider has loaded the card, then succeeded. If the card can't take it, the deployment either puts the money on the wallet (returned_to_wallet_at is set and the user gets a notification) or keeps it for support to finish (card_load_status: "failed"). Errors: target_not_allowed (this connector doesn't top up that target), card_not_eligible (the card isn't active or isn't prepaid), amount_too_small (a new card's price plus a starting balance don't fit in the amount).

Telegram Mini App

Run inside a Telegram Mini App? Exchange the Telegram-signed initData for a session in one call — no password:

import { fromTelegramWebApp } from "@takeal/cusfront-sdk/telegram";

// Reads window.Telegram.WebApp.initData, exchanges it, returns a ready client.
const client = await fromTelegramWebApp({
  baseUrl: "https://api.your-deployment.example.com",
});

const me = await client.auth.me();
if (me.email_pending) {
  // First-time Telegram users are auto-provisioned without an email.
  // Collect a real address and attach it:
  await client.auth.linkEmail({ email: "user@example.com" });
}

Already hold the raw string (e.g. from a custom launch)? Use fromInitData:

import { fromInitData, parseInitData } from "@takeal/cusfront-sdk/telegram";

const client = await fromInitData(initData, {
  baseUrl: "https://api.your-deployment.example.com",
});

How it works end-to-end:

  1. Telegram signs initData with the bot token when it launches your Mini App.
  2. The SDK does a fast structural check (hash + auth_date present, not stale) and POSTs the raw string to the API's exchange endpoint. The SDK cannot verify the cryptographic signature — only the server holds the bot token, so the API performs the authoritative HMAC check. A forged or stale payload is rejected there with a 401 ApiError.
  3. On success the JWT is stored in the configured token store; the returned client is authenticated for all client.* calls.
  4. First-time Telegram users are auto-provisioned. They have no email yet, so me.email_pending === true — prompt for an address and call client.auth.linkEmail to clear it.
  5. Once signed in, the Mini App registers itself as a telegram_miniapp device, so the user's notifications arrive as messages from the bot. A failure here is logged as a warning and never blocks the sign-in; pass { registerDevice: false } to skip it. The client also announces itself as telegram-miniapp in X-Takeal-Client (see Client platform).

For the lower-level call returning the raw session envelope (including email_pending), use client.auth.exchangeTelegram({ initData }) on a client you built yourself.

"I already have an account"

By default an unknown Telegram id gets a fresh account. That's wrong for a user who signed up on the website with an email and a password and then opens the Mini App: they'd end up with a second, empty account. Pass provision: false to find out first, then let the user choose:

import {
  fromTelegramWebApp,
  TelegramUnknownError,
} from "@takeal/cusfront-sdk/telegram";
import { createClient } from "@takeal/cusfront-sdk";

const config = { baseUrl: "https://api.your-deployment.example.com" };

try {
  // Known Telegram → signed-in client, as before.
  const client = await fromTelegramWebApp(config, { provision: false });
} catch (e) {
  if (!(e instanceof TelegramUnknownError)) throw e;

  // Nobody has signed in with this Telegram yet. Nothing was created.
  const client = createClient(config);
  const choice = await askUser(); // "create" | "link"

  if (choice === "create") {
    // Same as the default flow: a new account, email_pending === true.
    await client.auth.exchangeTelegram({ initData: e.initData, provision: true });
  } else {
    // Attach this Telegram to the existing email + password account.
    const { email, password } = await askCredentials();
    await client.auth.linkTelegram({ initData: e.initData, email, password });
  }
  // client is signed in either way.
}

If you'd rather branch on a value than catch an error, exchangeInitData returns both the client and the outcome:

import { exchangeInitData, readWebAppInitData } from "@takeal/cusfront-sdk/telegram";

const initData = readWebAppInitData()!;
const { client, session } = await exchangeInitData(initData, config, { provision: false });
if (session.stage === "unknown") {
  // client has no session yet; call client.auth.linkTelegram or
  // client.auth.exchangeTelegram({ initData, provision: true }).
}

linkTelegram checks the password the same way login does and returns the same stage: "jwt" envelope. On the next launch the plain exchange finds the link and signs the user straight in. Errors to expect:

  • 401 unauthenticated — wrong email or password.
  • 403 second_factor_required — the account signs in with a second factor; such accounts can't be linked from a Mini App.
  • 409 telegram_already_linked_other — the account already has a different Telegram. Unlink it first.
  • 409 telegram_in_use — this Telegram already belongs to another account that has been used. A Telegram can be attached to one account only, and the API does not merge accounts.

Two more calls for a signed-in user:

// Attach Telegram to the current session (e.g. the Mini App was opened
// through a deep link that already carried a session):
await client.me.linkTelegram(window.Telegram.WebApp.initData);

// Detach it. Refused with 409 `last_credential` when the account has no
// password, because Telegram would be its only way to sign in.
await client.me.unlinkTelegram();

The user object (client.auth.me() and the user in every stage: "jwt" envelope) carries the state, so a settings screen needs no extra request:

type User = {
  // ...
  telegram: { username: string | null; linked_at: string } | null;
};

Money history

One list of everything that happened to the user's money, newest first: card payments and declines, refunds, card issuance, card top-ups, transfers between the user's own cards, wallet top-ups, fees.

const page = await client.history.list({ limit: 30 });           // lang: "ru" for Russian titles
const older = await client.history.list({ cursor: page.next_cursor! });
const oneCard = await client.history.list({ cardId: card.id });

Each entry reads the same way: title, amount (always positive) with direction (out, in or internal), status and occurred_at.

  • amount is in the currency the operation happened in. When there was a conversion, counter_amount holds the other side and fx_rate the rate: a purchase in euros billed in dollars, or a top-up paid in roubles that arrived in dollars.
  • A declined payment has status: "declined" and a decline_reason with a stable code and a text to show as is.
  • A transfer between cards is one entry with card and to_card.
  • pending means the operation isn't finished yet.

New kinds will be added. Show an entry of an unknown kind with its title, amount and status rather than hiding it.

A bonus entry counts bonuses, not money: its amount.currency is BNS. When bonuses paid for something, counter_amount is the part of the price they covered.

Bonuses

Bonuses are a discount on the platform's services. They pay for part of a new card's price or of the subscription fee. Bonuses that came with a payment can also be added on top of a top-up; gifts can't.

const bonus = await client.bonus.get();      // balance, where they can go, credits, history

// "Was / now" before the user confirms, worked out on the server:
const p = await client.bonus.preview({ purpose: "card_issuance", amount: "10", currency: "USD", productId });
// p.amount "10", p.bonus_units "5", p.due "5"

const card = await client.cards.create({ productId, currency: "USD", idempotencyKey });
// card.bonus: { units, price, covered, paid, currency } when bonuses were used

const top = await client.deposits.initiate({ amount: "25", currency: "USD", method: "card", idempotencyKey });
// top.bonus_units / top.bonus_value: what was added on top of the 25
  • Bonuses are used by default whenever the user has some. Pass useBonus: false (cards) or use_bonus: false (deposits) to pay in money only, or bonusAmount / bonus_amount to cap how many go in. The subscription fee takes bonuses on its own.
  • Show the balance as a number of bonuses and nothing else. No money figure next to it: money belongs next to a price ("was / now", "N bonuses will be used").
  • In your copy, call bonuses a discount. Keep words like funds, withdraw or exchange away from them.

Promo and referral codes

A code goes with the operation it is for. At sign-up an unknown or expired code never blocks the account: it is created, and code_result says why the code wasn't applied.

// Sign-up on the site, with the code from the form or the link.
const s = await client.auth.register({ email, password, code: "SPRING" });
// s.code_result: { status: "applied" | "rejected", reason, message, effects }

// Mini App: a `startapp=<code>` link is read by the server; `code` overrides it.
const tg = await client.auth.exchangeTelegram({ initData });
// tg.code_result is set for a new account opened with a code

// Check a code before the user pays; nothing is used up.
const c = await client.codes.validate({ code: "SPRING", operation: "card_issuance", amount: "10", currency: "USD" });
// c.valid, c.discount, c.discount_value "5", c.effects

await client.cards.create({ productId, currency: "USD", promoCode: "SPRING", idempotencyKey });
await client.deposits.initiate({ amount: "100", currency: "USD", method: "card", promo_code: "SPRING", idempotencyKey });
await client.subscription.setPromoCode("SPRING"); // the monthly fee, while the code allows

// For referrers: the code, the link to share, friends (no names) and rewards.
const ref = await client.referral.get();
  • A code that can't be used on a card or a top-up fails the request with 400 promo_code_<reason> and a sentence to show.
  • A code the user signed up with keeps working on the operations its program covers, within its limits, so "half off the first card" can come with a sign-up link without the user typing it again.
  • client.subscription.get() shows a gift subscription as kind: "gift"; gift_until: null means it doesn't end.
  • Rewards and code bonuses are bonuses: a discount on our services, not money. Show friends without names; the API sends none.

Documents

Everything the operators write lives in one tree: blog posts, the knowledge base, legal pages. client.documents reads it. The calls work before the user signs in and show more once they have: a signed-in user also gets the articles marked for users. The SDK attaches the session token when it has one, so the same call serves the public site and the cabinet.

// The public blog, newest first.
const { documents, total } = await client.documents.list({ category: "blog", perPage: 10 });

// The knowledge base for a sidebar: folders plus the articles in them.
const tree = await client.documents.tree({ category: "knowledge-base" });
const roots = tree.folders.filter((f) => f.parent_id === null);

// One document by its permanent address.
const doc = await client.documents.get("how-to-top-up");

// Tags work across categories.
const tagged = await client.documents.list({ tag: "fees" });
const tags = await client.documents.tags();

A document comes as body_blocks (typed nodes to map onto your components) and body_markdown (for your own renderer). Image URLs are relative to the API origin; client.assetUrl(url) makes them absolute.

Links between documents arrive as site paths: /blog/<slug> for a post, /docs/<slug> for anything else. Each document's site_path says which one it lives at. Route those two prefixes to your document pages and the links keep working when a document is renamed or moved.

Every saved text is a version with the date it took effect. The footer links to the current text; a contract page can show what applied on a given day.

const offer = await client.documents.get("offer");               // in force now
const history = await client.documents.versions("offer");        // newest first
const old = await client.documents.get("offer", { at: "2026-01-01T00:00:00Z" });
const v2 = await client.documents.version("offer", 2);

A version dated ahead stays out of get() until its day comes, so a new redaction can be prepared in advance.

From client.blog

client.blog still works for one release and is marked deprecated. Same posts, same slugs: client.blog.list() is client.documents.list({ category: "blog" }) with documents instead of posts in the result, and client.blog.get(slug) is client.documents.get(slug).

Where users come from

Every account gets a record of where its owner came from: the first visit, the last one before sign-up and how many there were in between. The app's part is to report visits and to pass the visitor's key at sign-up.

import { anonymousKey, currentTouch } from "@takeal/cusfront-sdk";

// On every page load. A visit with nothing new is not stored, so there is
// no need to decide on the client side.
const key = anonymousKey();
if (key) await client.attribution.touch({ anonymous_key: key, ...currentTouch() });

// In the sign-up form.
await client.auth.register({ email, password, attribution: { anonymous_key: key } });

anonymousKey() keeps a random id in a cookie and in localStorage for a year. If one of the two is cleared, the id comes back from the other. Pass { cookieDomain: ".example.com" } when the site and the app live on different subdomains. In the Telegram Mini App the key is the Telegram id, and the server reads the startapp code from initData itself.

A visit counts as your campaign only when its code is in the operator's campaign directory. Put the code in utm_campaign, or in a Telegram link as startapp=<code>.

After sign-up, ask how the user heard about you, once:

const { survey_pending } = await client.attribution.status();
if (survey_pending) {
  // "friend" | "telegram_channel" | "search" | "ads" | "review" | "other" | "skipped"
  await client.attribution.answerSurvey("other", "A podcast");
}

Analytics

Call track() once per event and the SDK hands it on: to PostHog on every page, to Yandex Metrica on public pages only. Nothing is bundled. PostHog comes from your own posthog-js when you pass load, otherwise from its script; Metrica comes from its tag script. Neither loads until initAnalytics asks for it.

Every page belongs to a zone, and the zone decides what is collected:

public cabinet, miniapp
PostHog autocapture on, with element texts on, without texts or attributes
Heatmaps on off
Masking inputs every input and all text
Session recording off off; sessionRecording: true turns it on, masked
Yandex Metrica yes, without Webvisor never

Public pages

import { initAnalytics, track, identify, posthogDistinctId } from "@takeal/cusfront-sdk/analytics";

initAnalytics({
  zone: "public",
  posthog: { key: "phc_…", host: "https://example.com/ingest" },
  metrika: { counterId: 12345678 },
});

track("signup_started");

// The sign-up form.
const session = await client.auth.register({
  email,
  password,
  attribution: { anonymous_key: key, posthog_distinct_id: await posthogDistinctId() },
});
track("signup_completed");
identify(session.user.id);

Cabinet

import { initAnalytics, identify, resetAnalytics, NO_CAPTURE_CLASS } from "@takeal/cusfront-sdk/analytics";

initAnalytics({
  zone: "cabinet",
  posthog: {
    key: "phc_…",
    host: "https://example.com/ingest",
    load: () => import("posthog-js").then((m) => m.default), // posthog-js 1.187 or later
  },
});

identify(session.user.id); // after sign-in
resetAnalytics();          // on sign-out

Put card details, balances and the operations history inside NO_CAPTURE_CLASS (ph-no-capture). PostHog leaves these blocks out of autocapture and blanks them in recordings.

<div className={NO_CAPTURE_CLASS}>{balance}</div>

Turn on sessionRecording: true last, after you have watched one of your own cabinet sessions in PostHog and seen no values in it at all: no card number, balance, amount or e-mail.

Telegram Mini App

The same as the cabinet, with zone: "miniapp".

Metrica only on public pages

Metrica never loads in the cabinet or in the Mini App, even when metrika is passed. From the browser it gets page views and two goals, signup_started and signup_completed. Deposits, cards and subscriptions reach Metrica from the server with their amount in roubles, so the browser doesn't send them. Webvisor, the click map and link tracking are off: Metrica sees only what the SDK sends it.

When the site and the cabinet are one app, call setAnalyticsZone("cabinet") as the user enters the cabinet. PostHog switches to masking, and Metrica stops receiving anything. A full page load into the cabinet is safer, because then Metrica isn't on the page at all. If initAnalytics finds Metrica on a cabinet page, it warns in the console.

Events and page views

track(name, props) accepts only names from ANALYTICS_EVENTS, and any other name is a type error. Besides the two sign-up events, the list holds the platform's own event types (deposit_confirmed, card_issued and the rest), so an event has the same name in PostHog and in reports built from the database.

Call page() on every route change. initAnalytics sends the first page view itself, and the same address twice in a row counts once.

Identify users by the platform's user id (session.user.id), never by e-mail. PostHog creates profiles for identified users only.

What never leaves the browser

Every event goes through one filter, PostHog's autocapture included:

  • in page addresses, UUIDs, numeric ids and long tokens become :id, query parameters with secrets or personal data are dropped, and so is the fragment. Campaign tags stay;
  • properties named email, phone, name, first_name and the like are dropped, and so is any value that looks like an e-mail address, a phone number or a card number;
  • amounts and currencies pass.

The same filter is exported as sanitizeUrl and sanitizeProperties.

A proxy on your own domain

Ad blockers cut direct requests to PostHog, so serve it from a path on your domain and pass that path as host. The path should:

  • forward /static/* to https://eu-assets.i.posthog.com/static/* and everything else to https://eu.i.posthog.com;
  • be left out of the service worker's offline cache, and never cached anywhere else either;
  • stay out of the sitemap and be closed to indexing (Disallow in robots.txt, X-Robots-Tag: noindex).

With Next.js:

// next.config.js
export default {
  skipTrailingSlashRedirect: true,
  async rewrites() {
    return [
      { source: "/ingest/static/:path*", destination: "https://eu-assets.i.posthog.com/static/:path*" },
      { source: "/ingest/:path*", destination: "https://eu.i.posthog.com/:path*" },
    ];
  },
};

With Workbox, add /^\/ingest\// to navigateFallbackDenylist and give the path no runtime caching route.

Notifications (inbox)

Everything the API tells a user (a confirmed deposit, a card transaction, a sign-in from a new device) is kept as an inbox item, whatever else was sent as a nudge (a push, a Telegram message, an email). The inbox is what your app shows; client.notifications reads it.

const page = await client.notifications.list({ limit: 20 });
// page.items (newest first), page.next_cursor, page.unread_count

const more = await client.notifications.list({ cursor: page.next_cursor! });
const onlyMoney = await client.notifications.list({ unreadOnly: true, category: "money" });

await client.notifications.markRead(page.items[0].id);
await client.notifications.markAllRead();          // or markAllRead(isoTimestamp)
await client.notifications.remove(page.items[1].id);
const badge = await client.notifications.unreadCount();

An item has title, body, a category (money, card, security, account, marketing), read_at (null while unread), free-form data about the event, and href: a path inside your app to open on tap (/cards/<id>, /deposits/<id>, /subscription, /settings/sessions) or null. Route href through your own router; the SDK doesn't navigate.

A small inbox screen:

function Inbox() {
  const [page, setPage] = useState<NotificationPage | null>(null);
  useEffect(() => { client.notifications.list({ limit: 20 }).then(setPage); }, []);
  if (!page) return <Spinner />;
  return (
    <ul>
      {page.items.map((n) => (
        <li key={n.id} className={n.read_at ? "" : "unread"}
            onClick={async () => { await client.notifications.markRead(n.id); if (n.href) router.push(n.href); }}>
          <strong>{n.title}</strong>
          <p>{n.body}</p>
        </li>
      ))}
    </ul>
  );
}

(The React entry has useNotifications() and useUnreadCount(), which do the state handling for you; see React hooks.)

Preferences

Where the nudge goes is up to the user:

const prefs = await client.notifications.getPreferences();
// prefs.primary_channel: "push" | "telegram" | "email" | "sms" | null (automatic)
// prefs.categories: { money: { push: true, email: false }, ... }
// prefs.locale: "en" | "ru" | null
// prefs.available_channels: what this deployment can deliver on; render toggles only for these

await client.notifications.setPreferences({
  primary_channel: "telegram",
  categories: { marketing: { email: true, telegram: false } },
  locale: "ru",
});

Every field of setPreferences is optional; only what you pass changes. primary_channel: null goes back to automatic (the server picks by the user's devices). security and money can't be switched off completely: the inbox always gets them and at least one outward channel stays on. marketing is off until the user opts in. A locale of null means "use the language of the Telegram profile, else the deployment's default".

client.subscriptions (announcement channels) is deprecated: it now reads and writes categories.marketing and keeps working, but new code should use setPreferences({ categories: { marketing: { ... } } }).

Devices

A user has many devices: each browser with a Web Push subscription, each phone with an FCM or APNs token, and a marker that says "uses the Telegram Mini App". The server picks where a notification goes from them: a mobile app first, otherwise the device seen most recently. What each platform can receive:

Platform Outward channel Inbox
PWA / browser Web Push yes
Telegram Mini App messages from the bot (no Web Push) yes
iOS / Android app FCM / APNs (tokens are accepted now; delivery comes later) yes

Web Push in a browser, once notification permission is granted:

const reg = await navigator.serviceWorker.ready;
if ((await Notification.requestPermission()) === "granted") {
  const device = await client.devices.registerWebPush(reg, { label: "Chrome on Mac" });
  // device.id, device.vapid_public_key
}

// Settings screen, later:
await client.devices.unregisterWebPush(reg);              // this session's Web Push devices
await client.devices.unregisterWebPush(reg, { deviceId }); // or exactly one

registerWebPush fetches the deployment's VAPID key, subscribes the browser and registers the subscription as a webpush device on the client's platform (pwa or web). It throws when push isn't set up on the deployment. Pass vapidPublicKey in the options to skip the key lookup when you already hold it.

Anything else goes through register / list / remove:

// A native app with a push token:
await client.devices.register({ kind: "fcm", platform: "android", credential: { token }, app_version: "1.4.0" });
// A Telegram Mini App (done for you by @takeal/cusfront-sdk/telegram):
await client.devices.register({ kind: "telegram_miniapp", platform: "telegram", credential: {} });

const devices = await client.devices.list();   // is_current marks the ones from this session
await client.devices.remove(devices[0].id);

Registering the same credential twice refreshes the device (and re-enables a disabled one) instead of creating another. app_version defaults to the version in X-Takeal-Client.

client.push (enable, disable, save, remove, status) is deprecated. The endpoint behind it keeps working as an alias over Web Push devices, but new code should use devices.registerWebPush / devices.unregisterWebPush.

Your service worker shows the notification; the payload is JSON with title, body and url. To keep an open tab's badge fresh, also post a message to the open clients:

self.addEventListener("push", (event) => {
  const payload = event.data.json();
  event.waitUntil(Promise.all([
    self.registration.showNotification(payload.title, { body: payload.body, data: { url: payload.url } }),
    self.clients.matchAll({ type: "window" }).then((clients) =>
      clients.forEach((c) => c.postMessage({ type: "takeal:push" }))),
  ]));
});

Client platform (X-Takeal-Client)

Every request carries X-Takeal-Client: <platform>; <version>, for example pwa; 0.3.0 or telegram-miniapp; 0.2.8. The server stores it on the session and uses it to notice a sign-in from a new device and to choose the notification channel. Set it with the client option:

const client = createClient({
  baseUrl: "https://api.your-deployment.example.com",
  client: { platform: "ios", version: "1.4.0" },
});
client.client; // { platform: "ios", version: "1.4.0" }

platform is one of pwa, web, telegram-miniapp, ios, android. When you leave the option out, the SDK detects it: telegram-miniapp when window.Telegram.WebApp.initData is present, pwa when the page runs installed (navigator.standalone or a display-mode: standalone media query), web otherwise and on any non-browser runtime. Native wrappers (Capacitor, React Native) can't be told apart from a browser, so pass ios or android yourself. version defaults to the SDK version (SDK_VERSION).

Platform version (client.ready())

Each SDK release works with a range of platform versions, published as SUPPORTED_MEISTER (for example ">=0.5.0, <0.6.0"). client.ready() calls GET /ready (public, no login) and checks the version the deployment reports against that range:

import { createClient, SUPPORTED_MEISTER } from "@takeal/cusfront-sdk";

const client = createClient({ baseUrl: "https://api.your-deployment.example.com" });
const r = await client.ready();
// {
//   compatible: true,
//   platformVersion: "0.5.0",
//   supportedMeister: ">=0.5.0, <0.6.0",
//   healthy: true,
//   status: "ready",
//   raw: { ... }   // the full /ready body
// }
if (!r.compatible) console.warn(r.warning);

compatible is false, with a one-line warning, in two cases: the version is outside the range, or the platform didn't report one (deployments older than 0.5.0). A degraded platform answers 503 with the same body; ready() returns it with healthy: false rather than throwing. Network failures and other errors throw as usual. satisfies(version, range) is exported too if you want to run the same check yourself.

Brand config

Whitelabel-friendly by design — the SDK ships no embedded brand. Pass brand at runtime and the client app reads it back via client.brand:

type BrandConfig = {
  name: string;
  logoUrl?: string;
  primaryColor?: string;
  supportUrl?: string;
  walletLabel?: string; // how the user's balance is called, e.g. "Acme Wallet"
};

Switching brands does not require forking or re-publishing the SDK.

The deployment also publishes its live brand config at GET /branding (public, no login needed) — client.branding.get() returns platform_name, merchant_portal_name, logo_url, favicon_url, wallet_label and the primary_color / secondary_color / accent_color hex values, so a client app can re-theme itself at runtime and call the balance whatever the operator configured. Server values win over the build-time brand when both are present.

Sub-exports

Tree-shaking-friendly: import only the resource you need.

import { AuthResource } from "@takeal/cusfront-sdk/auth";

Available now:

  • @takeal/cusfront-sdk — createClient + types.
  • @takeal/cusfront-sdk/auth — auth-only entry.
  • @takeal/cusfront-sdk/deposits — DepositsResource (money IN via a funder connector).
  • @takeal/cusfront-sdk/funding-options — FundingOptionsResource (the ways to top up; see Topping up a card directly).
  • @takeal/cusfront-sdk/cards — CardsResource (cards backed by the wallet balance).
  • @takeal/cusfront-sdk/balance — BalanceResource (per-currency wallet balance).
  • @takeal/cusfront-sdk/bonus — BonusResource (bonuses and a price preview; see Bonuses).
  • @takeal/cusfront-sdk/documents — DocumentsResource (blog posts, the knowledge base, legal pages; see Documents).
  • @takeal/cusfront-sdk/blog — BlogResource (deprecated: the blog is a category of Documents now).
  • @takeal/cusfront-sdk/subscriptions — SubscriptionsResource (announcement channels; deprecated, see Preferences).
  • @takeal/cusfront-sdk/wallet — WalletResource (the wallet's currency).
  • @takeal/cusfront-sdk/sessions — SessionsResource (where the user is signed in).
  • @takeal/cusfront-sdk/push — PushResource (Web Push subscription; deprecated, see Devices).
  • @takeal/cusfront-sdk/notifications — NotificationsResource (the inbox and notification preferences).
  • @takeal/cusfront-sdk/devices — DevicesResource (Web Push, mobile push tokens, the Telegram Mini App marker).
  • @takeal/cusfront-sdk/me — MeResource (link or unlink Telegram for the signed-in user).
  • @takeal/cusfront-sdk/history — HistoryResource (the user's money history).
  • @takeal/cusfront-sdk/attribution — AttributionResource, anonymousKey, currentTouch (where users come from).
  • @takeal/cusfront-sdk/webhooks — HMAC-SHA256 signature verifier (no network, isomorphic).
  • @takeal/cusfront-sdk/react — optional React hooks layer (ClientProvider + useMe / useDeposits / useCards / useCardProducts / useBalance / useNotifications / useUnreadCount). React is a peerDependency, never bundled.
  • @takeal/cusfront-sdk/telegram — Telegram Mini App initData → authenticated client bridge.
  • @takeal/cusfront-sdk/analytics — initAnalytics, track, page, identify, resetAnalytics, posthogDistinctId (PostHog and Yandex Metrica behind one call; see Analytics).

Verifying webhooks

verifyWebhook is pure (no network) and isomorphic (Web Crypto — Node 18+, browsers, Bun, Deno, Workers). Verify against the raw request body bytes — not re-serialised JSON — using the secret your endpoint was provisioned with:

import { verifyWebhook, SIGNATURE_HEADER } from "@takeal/cusfront-sdk/webhooks";

const ok = await verifyWebhook({
  payload: rawBody,                              // string or Uint8Array, verbatim
  signatureHeader: req.headers[SIGNATURE_HEADER.toLowerCase()],
  secret: process.env.TAKEAL_WEBHOOK_SECRET!,
});
if (!ok) return res.status(401).end();

Algorithm: HMAC-SHA256, header X-Takeal-Signature: sha256=<hex>, signed over the raw body bytes (no timestamp). Comparison is constant-time.

Card details: client.cards.reveal() returns the full card number and CVV to the signed-in user, along with the billing address the card is registered at, which merchants ask for at checkout; no password or code is asked again, and the API rate-limits and logs every call. The rest of client.cards is catalog, listProducts, getProduct, create, get, list, update, balance, topUpTerms, topUp, freeze, unfreeze and terminate.

React hooks (@takeal/cusfront-sdk/react)

An optional React layer ships from a separate entry point. React is a peerDependency (>=18) and is never bundled, so non-React consumers pay nothing for it. Install React in your app, then:

pnpm add react   # if not already present

Wrap your tree once in a ClientProvider, then read data with the hooks:

import { createClient } from "@takeal/cusfront-sdk";
import { ClientProvider, useBalance } from "@takeal/cusfront-sdk/react";

// Build the client once — module scope or a useMemo, not per-render.
const client = createClient({
  baseUrl: "https://api.your-deployment.example.com",
});

function Root() {
  return (
    <ClientProvider client={client}>
      <Wallet />
    </ClientProvider>
  );
}

function Wallet() {
  const { data, error, loading, refetch } = useBalance("USD");

  if (loading) return <Spinner />;
  if (error) return <ErrorBanner onRetry={refetch} />;
  return (
    <div>
      {data!.amount} {data!.currency}
      <button onClick={() => void refetch()}>Refresh</button>
    </div>
  );
}

Every hook returns the same shape — { data, error, loading, refetch }:

  • useMe() — current authenticated user (client.auth.me()).
  • useDeposits() — the user's deposits (client.deposits.list()).
  • useCards() — the user's cards (client.cards.list()).
  • useCardProducts() — the cards the user can order (client.cards.listProducts()).
  • useBalance(currency) — wallet balance for one currency (client.balance.get(currency)); re-fetches when currency changes.
  • useUnreadCount({ pollMs? }) — unread inbox items for a badge (client.notifications.unreadCount()). Refreshes in the background every pollMs (default 30000; 0 turns polling off) and whenever the page's service worker posts { type: "takeal:push" } (see Devices), without flipping loading.

useNotifications({ unreadOnly?, category?, limit? }) returns the same shape plus markRead(id), markAllRead(), loadMore() and loadingMore. loadMore fetches the next page by next_cursor and appends its items to data.items; the two mark helpers update data right away so the list doesn't flicker.

function Inbox() {
  const inbox = useNotifications({ limit: 20 });
  const unread = useUnreadCount();

  if (inbox.loading) return <Spinner />;
  if (inbox.error) return <ErrorBanner onRetry={inbox.refetch} />;
  return (
    <>
      <h2>Inbox {unread.data ? `(${unread.data})` : ""}</h2>
      <button onClick={() => void inbox.markAllRead()}>Mark all read</button>
      {inbox.data!.items.map((n) => (
        <Row key={n.id} unread={!n.read_at} onClick={() => void inbox.markRead(n.id)}>
          {n.title}
        </Row>
      ))}
      {inbox.data!.next_cursor && (
        <button disabled={inbox.loadingMore} onClick={() => void inbox.loadMore()}>More</button>
      )}
    </>
  );
}

useClient() exposes the raw client from context for one-off writes (e.g. client.deposits.initiate(...)) — it throws a clear error if called outside a ClientProvider.

The hooks are SSR-safe (fetches run only inside useEffect, never during server render) and have no third-party data-fetching dependency. In-flight requests are guarded against unmounted-component writes.

Token storage

createClient accepts a pluggable TokenStore. The default is:

  • Browser: localStorage (key takeal_jwt).
  • Node / SSR / Worker: in-memory.
  • Capacitor / React Native: pass your own (Keychain / EncryptedSharedPreferences wrapper).
import { createClient, inMemoryStore } from "@takeal/cusfront-sdk";

const client = createClient({
  baseUrl: "...",
  tokenStore: inMemoryStore(), // never persist
});

Errors

Two narrow error types — branch on the type guard, not instanceof:

import { isApiError, isNetworkError } from "@takeal/cusfront-sdk";

try {
  await client.auth.login({ email, password });
} catch (e) {
  if (isApiError(e)) {
    // e.status, e.code, e.message, e.body
    if (e.code === "invalid_credentials") showInlineError();
  } else if (isNetworkError(e)) {
    showOfflineBanner();
  } else {
    throw e;
  }
}

Development

pnpm install
pnpm refresh-types   # regenerate src/types.gen.ts from openapi-snapshot.json
pnpm build           # tsup → dist/
pnpm test            # vitest
pnpm typecheck       # tsc --noEmit

The OpenAPI snapshot lives at openapi-snapshot.json and is committed; refresh it from a running Takeal deployment with:

curl https://api.your-deployment.example.com/docs/openapi.json > openapi-snapshot.json
pnpm refresh-types

License

MIT — see LICENSE.