- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| scripts | ||
| src | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| RELEASE.md | ||
| tsconfig.json | ||
| tsup.config.ts | ||
| vitest.config.ts | ||
@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:
- Telegram signs
initDatawith the bot token when it launches your Mini App. - The SDK does a fast structural check (
hash+auth_datepresent, 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 a401 ApiError. - On success the JWT is stored in the configured token store; the returned
client is authenticated for all
client.*calls. - First-time Telegram users are auto-provisioned. They have no email yet, so
me.email_pending === true— prompt for an address and callclient.auth.linkEmailto clear it. - Once signed in, the Mini App registers itself as a
telegram_miniappdevice, 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 astelegram-miniappinX-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.
amountis in the currency the operation happened in. When there was a conversion,counter_amountholds the other side andfx_ratethe 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 adecline_reasonwith a stablecodeand atextto show as is. - A transfer between cards is one entry with
cardandto_card. pendingmeans 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) oruse_bonus: false(deposits) to pay in money only, orbonusAmount/bonus_amountto 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 askind: "gift";gift_until: nullmeans 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.
Legal pages and their history
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_nameand 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/*tohttps://eu-assets.i.posthog.com/static/*and everything else tohttps://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 (
Disallowinrobots.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 apeerDependency, never bundled.@takeal/cusfront-sdk/telegram— Telegram Mini AppinitData→ 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 ofclient.cardsis 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 whencurrencychanges.useUnreadCount({ pollMs? })— unread inbox items for a badge (client.notifications.unreadCount()). Refreshes in the background everypollMs(default 30000;0turns polling off) and whenever the page's service worker posts{ type: "takeal:push" }(see Devices), without flippingloading.
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(keytakeal_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.