Схема архитектуры интеграции
.png?fit=max&auto=format&n=sYcwF1WqD1mDpark&q=85&s=361edaa262f2422e975ef7fc85303181)
API-эндпоинты
Backend API
| Environment | Endpoint |
|---|---|
| Production | https://card-prod.whales-api.com |
| Staging/Testnet | https://card-staging.whales-api.com |
URL веб-приложения
| Environment | URL* |
|---|---|
| Production | https://yourwallet.holders.io |
| Staging/Testnet | https://yourwallet-stage.holders.io |
yourwallet будут использоваться URL с названием вашего кошелька.
Процесс аутентификации
Общий поток аутентификации

Структура proof
Для аутентификации используется протокол TonConnect v2. Необходимо сформировать proof, то есть доказательство владения кошельком, для TON и, при необходимости, для Solana.Структура TON Proof
interface TonProof {
timestamp: number; // Unix timestamp in seconds
domain: {
lengthBytes: number; // Domain length in bytes
value: string; // Domain (for example, "holders.io")
};
signature: string; // Base64 signature
payload: string; // Arbitrary payload from the server
walletStateInit: string; // Wallet StateInit in base64
publicKey: string; // Public key in hex
}
Алгоритм создания TON Proof
// 1. Construct the message to sign
const messageBuffer = Buffer.concat([
Buffer.from('ton-proof-item-v2/'),
addressBuffer, // workchain (4 bytes BE) + hash (32 bytes)
domainLengthBuffer, // 4 bytes LE
domainBuffer, // domain string
timestampBuffer, // 8 bytes LE (Int64)
Buffer.from(payload) // payload string
]);
// 2. Hash it
const message = sha256(messageBuffer);
// 3. Add prefix
const bufferToSign = Buffer.concat([
Buffer.from('ffff', 'hex'),
Buffer.from('ton-connect'),
message
]);
// 4. Sign
const signature = nacl.sign.detached(sha256(bufferToSign), secretKey);
interface SolanaProof {
timestamp: number;
domain: {
lengthBytes: number;
value: string;
};
signature: string; // Base58 encoded signature
payload: string;
publicKey: string; // Base58 public key
}
Формат запроса на аутентификацию
// Request for TON + Solana
type TonSolanaAuthRequest = [
{
stack: 'ton';
network: 'ton-mainnet' | 'ton-testnet';
key: {
kind: 'tonconnect-v2';
wallet: 'yourwallet';
config: {
address: string; // Raw address format
proof: TonProof;
};
};
inviteId?: string; // Optional invite code
invitationId?: string; // Optional invitation ID
appsflyerId?: string; // AppsFlyer UID for analytics
},
{
stack: 'solana';
network: 'solana-mainnet' | 'solana-devnet';
key: {
kind: 'tonconnect-v2';
wallet: 'yourwallet';
config: {
address: string; // Base58 address
proof: SolanaProof;
};
};
}
];
API-методы
Подключение кошелька, Enrollment
Endpoint:POST /v2/user/wallet/connectЗапрос:
[
{
"stack": "ton",
"network": "ton-mainnet",
"key": {
"kind": "tonconnect-v2",
"wallet": "yourwallet",
"config": {
"address": "0:...",
"proof": {
"timestamp": 1706284800,
"domain": { "lengthBytes": 10, "value": "holders.io" },
"signature": "base64...",
"payload": "ton-proof-any",
"walletStateInit": "base64...",
"publicKey": "hex..."
}
}
},
"inviteId": "optional-invite-code"
},
{
"stack": "solana",
"network": "solana-mainnet",
"key": {
"kind": "tonconnect-v2",
"wallet": "yourwallet",
"config": {
"address": "base58...",
"proof": {
"timestamp": 1706284800,
"domain": { "lengthBytes": 10, "value": "holders.io" },
"signature": "base58...",
"payload": "solana-proof-any",
"publicKey": "base58..."
}
}
}
}
]
{
"ok": true,
"token": "jwt-token-string"
}
Получение состояния пользователя
Endpoint:POST /account/stateЗапрос:
{
"token": "jwt-token"
}
{
"ok": true,
"state": {
"state": "ok",
"notificationSettings": { "enabled": true },
"suspended": false
}
}
Список аккаунтов, приватный метод
Endpoint:POST /v2/account/listЗапрос:
{
"token": "jwt-token"
}
{
"ok": true,
"list": [
{
"id": "account-id",
"type": "crypto",
"accountIndex": 0,
"address": "EQ...",
"state": "ACTIVE",
"balance": "1000000000",
"network": "ton-mainnet",
"cards": [
{
"id": "card-id",
"status": "ACTIVE",
"lastFourDigits": "1234",
"productId": "product-id"
}
],
"limits": {
"daily": "100000000",
"monthly": "1000000000",
"onetime": "50000000"
}
}
],
"prepaidCards": []
}
Публичные данные аккаунта, авторизация не требуется
Endpoint:POST /v2/public/accountsЗапрос:
{
"walletKind": "yourwallet",
"wallets": [
{
"network": "ton-mainnet",
"address": "EQ..."
},
{
"network": "solana",
"address": "base58..."
}
]
}
{
"ok": true,
"accounts": [
{
"id": "account-id",
"accountIndex": 0,
"address": "EQ...",
"state": "ACTIVE",
"balance": "1000000000",
"network": "ton-mainnet",
"cards": [
{
"lastFourDigits": "1234",
"productId": "product-id"
}
]
}
]
}
Проверка доступа, Invite Check
Endpoint:POST /v2/invite/wallet/check/v2Запрос:
{
"wallet": "EQ...",
"network": "ton-mainnet",
"region": {
"countryCode": "US",
"storeFrontCode": "usa"
},
"campaignId": "optional-campaign",
"campaignParams": {}
}
{
"allowed": true,
"banner": {
"id": 1,
"content": {
"action": { "en": "Get Card", "ru": "Получить карту" },
"title": { "en": "Welcome!", "ru": "Добро пожаловать!" },
"subtitle": { "en": "...", "ru": "..." }
}
}
}
Профиль пользователя
Endpoint:POST /v2/profile/getЗапрос:
{
"token": "jwt-token"
}
{
"ok": true,
"data": {
"userId": "user-id",
"email": "user@example.com",
"emailVerified": true,
"phone": "+1234567890",
"country": "US",
"firstName": "John",
"lastName": "Doe"
}
}
Список карточных транзакций
Endpoint:POST /v2/card/eventsЗапрос:
{
"token": "jwt-token",
"cardId": "card-id",
"limit": 40,
"cursor": "optional-cursor",
"order": "desc"
}
{
"ok": true,
"data": {
"events": [
{
"type": "charge",
"id": "event-id",
"cardId": "card-id",
"time": 1706284800,
"data": {
"accountId": "account-id",
"amount": "1000000000",
"currency": "USD",
"currencyAmount": "10.00"
}
}
],
"cursor": "next-cursor",
"more": true
}
}
Данные для Apple Pay
Endpoint:POST /v2/card/provisioning/listЗапрос:
{
"token": "jwt-token"
}
{
"ok": true,
"cards": [
{
"id": "card-id",
"lastFourDigits": "1234",
"title": "Holders Card",
"cardholderName": "JOHN DOE",
"assetName": "card-asset",
"assetUrl": "https://..."
}
]
}
Интеграция WebView
Схема архитектуры WebView

URL-параметры
При загрузке Holders WebApp нужно передать следующие query-параметры:const queryParams = new URLSearchParams({
lang: 'en', // Language: 'en' | 'ru'
currency: 'USD', // Display currency
theme: 'holders', // Theme
'theme-style': 'dark', // 'dark' | 'light'
bounceable: 'false', // Address format
'initial-route': '/accounts' // Initial route
});
const url = `https://tonhub.holders.io?${queryParams.toString()}`;
Типы
| Имя параметра | Значение |
|---|---|
| lang | en |
| currency | код валюты |
| theme | код темы |
| theme-style | dark |
const supportedCurrencies = [
{ code: 'USD', symbol: '$', label: 'U.S. Dollar' },
{ code: 'EUR', symbol: '€', label: 'Euro' },
{ code: 'RUB', symbol: '₽', label: 'Russian Ruble' },
{ code: 'GBP', symbol: '£', label: 'British Pound' },
{ code: 'CHF', symbol: '₣', label: 'Swiss Franc' },
{ code: 'CNY', symbol: '¥', label: 'Chinese Yuan' },
{ code: 'KRW', symbol: '₩', label: 'South Korean Won' },
{ code: 'IDR', symbol: 'Rp', label: 'Indonesian Rupiah' },
{ code: 'INR', symbol: '₹', label: 'Indian Rupee' },
{ code: 'JPY', symbol: '¥', label: 'Japanese Yen' }
];
Инъекция начального состояния
const initialState = {
user: {
status: {
state: 'ok',
kycStatus: null,
suspended: false
},
token: 'jwt-token'
},
accountsList: [...],
prepaidCards: [...]
};
const injectedJS = `
window.initialState = ${JSON.stringify(initialState)};
`;
Мост TonConnect для WebView
interface TonConnectInjectedBridge {
deviceInfo: DeviceInfo;
protocolVersion: number;
isWalletBrowser: boolean;
// Connect a new app
connect(
protocolVersion: number,
request: ConnectRequest
): Promise<ConnectEvent>;
// Restore an existing connection
restoreConnection(): Promise<ConnectEvent>;
// Disconnect
disconnect(): Promise<void>;
// Send requests (transactions, data signing)
send<T extends RpcMethod>(request: AppRequest<T>): Promise<WalletResponse<T>>;
}
Query API
WebView может управлять нативным приложением через URL-параметры:| Параметр | Значения | Описание |
|---|---|---|
closeApp | true | Закрыть WebView |
openEnrollment | true | Запустить процесс регистрации |
openUrl | URL | Открыть URL в браузере |
backPolicy | back / close / lock | Поведение кнопки «Назад» |
lockScroll | true / false | Блокировка прокрутки |
showKAV | true / false | Показать дополнительную панель клавиатуры |
Состояния пользователя
Схема переходов между состояниями пользователя

Обработка состояний
enum HoldersUserState {
NeedEnrollment = 'need-enrollment',
NeedPhone = 'need-phone',
NoRef = 'no-ref',
NeedKyc = 'need-kyc',
NeedEmail = 'need-email',
Ok = 'ok',
}
async function handleUserState(token: string): Promise<void> {
const state = await fetchUserState(token);
switch (state.state) {
case HoldersUserState.NeedEnrollment:
// Show the registration screen
navigateToEnrollment();
break;
case HoldersUserState.NoRef:
// Show the waiting/waitlist screen
showWaitlistScreen();
break;
case HoldersUserState.NeedPhone:
// Redirect to phone verification in WebView
openHoldersWebView('/phone-verification');
break;
case HoldersUserState.NeedKyc:
// Redirect to KYC in WebView
openHoldersWebView('/kyc');
break;
case HoldersUserState.NeedEmail:
// Redirect to email verification in WebView
openHoldersWebView('/email-verification');
break;
case HoldersUserState.Ok:
// Full access
openHoldersWebView('/accounts');
break;
}
}
Примеры кода
TypeScript: полный поток аутентификации
import { Address, beginCell, storeStateInit } from '@ton/core';
import { sha256_sync } from '@ton/crypto';
import nacl from 'tweetnacl';
import { Int64LE } from 'int64-buffer';
interface WalletKeys {
publicKey: Buffer;
secretKey: Buffer;
}
interface EnrollmentResult {
success: boolean;
token?: string;
error?: string;
}
class HoldersIntegration {
private endpoint: string;
private isTestnet: boolean;
constructor(isTestnet: boolean = false) {
this.isTestnet = isTestnet;
this.endpoint = isTestnet
? 'https://card-staging.whales-api.com'
: 'https://card-prod.whales-api.com';
}
/**
* TON Proof creating
*/
private createTonProof(
address: Address,
keys: WalletKeys,
domain: string,
payload: string,
walletStateInit: string
) {
const timestamp = Math.floor(Date.now() / 1000);
const timestampBuffer = new Int64LE(timestamp).toBuffer();
const domainBuffer = Buffer.from(domain);
const domainLengthBuffer = Buffer.allocUnsafe(4);
domainLengthBuffer.writeInt32LE(domainBuffer.byteLength);
const rawAddress = address.toRawString();
const [workchain, addrHash] = rawAddress.split(':');
const addressWorkchainBuffer = Buffer.allocUnsafe(4);
addressWorkchainBuffer.writeInt32BE(Number(workchain));
const addressBuffer = Buffer.concat([
addressWorkchainBuffer,
Buffer.from(addrHash, 'hex'),
]);
const messageBuffer = Buffer.concat([
Buffer.from('ton-proof-item-v2/'),
addressBuffer,
domainLengthBuffer,
domainBuffer,
timestampBuffer,
Buffer.from(payload),
]);
const message = sha256_sync(messageBuffer);
const bufferToSign = Buffer.concat([
Buffer.from('ffff', 'hex'),
Buffer.from('ton-connect'),
message,
]);
const signed = nacl.sign.detached(
sha256_sync(bufferToSign),
Uint8Array.from(keys.secretKey)
);
return {
timestamp,
domain: {
lengthBytes: domainBuffer.byteLength,
value: domain,
},
signature: Buffer.from(signed).toString('base64'),
payload,
walletStateInit,
publicKey: keys.publicKey.toString('hex'),
};
}
/**
* Fetching the application manifest
*/
async getManifest() {
const manifestUrl = this.isTestnet
? 'https://yourwallet-stage.holders.io/jsons/tonconnect-manifest.json'
: 'https://yourwallet.holders.io/jsons/tonconnect-manifest.json';
const response = await fetch(manifestUrl);
return response.json();
}
/**
* Wallet connect (enrollment)
*/
async enrollWallet(
address: Address,
keys: WalletKeys,
walletStateInit: string,
inviteId?: string
): Promise<EnrollmentResult> {
try {
const manifest = await this.getManifest();
const domain = new URL(manifest.url).hostname;
const tonProof = this.createTonProof(
address,
keys,
domain,
'ton-proof-any',
walletStateInit
);
const requestBody = [
{
stack: 'ton',
network: this.isTestnet ? 'ton-testnet' : 'ton-mainnet',
key: {
kind: 'tonconnect-v2',
wallet: 'yourwallet',
config: {
address: address.toRawString(),
proof: tonProof,
},
},
inviteId,
},
];
const response = await fetch(`${this.endpoint}/v2/user/wallet/connect`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
const data = await response.json();
if (!data.ok) {
return { success: false, error: 'Failed to fetch user token' };
}
return { success: true, token: data.token };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Fetching the user state
*/
async getUserState(token: string) {
const response = await fetch(`${this.endpoint}/account/state`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (response.status === 401) {
return null;
}
const data = await response.json();
if (!data.ok) {
throw new Error('Failed to fetch account state');
}
return data.state;
}
/**
* Fetching the account list
*/
async getAccounts(token: string) {
const response = await fetch(`${this.endpoint}/v2/account/list`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!data.ok) {
throw new Error('Failed to fetch accounts');
}
return {
accounts: data.list,
prepaidCards: data.prepaidCards,
};
}
/**
* Public account data (no authorization)
*/
async getPublicAccounts(address: string, solanaAddress?: string) {
const body = solanaAddress
? {
walletKind: 'yourwallet',
wallets: [
{ network: this.isTestnet ? 'ton-testnet' : 'ton-mainnet', address },
{ network: 'solana', address: solanaAddress },
],
}
: {
walletKind: 'yourwallet',
network: this.isTestnet ? 'ton-testnet' : 'ton-mainnet',
address,
};
const response = await fetch(`${this.endpoint}/v2/public/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await response.json();
if (!data.ok) {
throw new Error('Failed to fetch public accounts');
}
return data.accounts;
}
/**
* Access check
*/
async checkInvite(address: string, countryCode: string) {
const response = await fetch(`${this.endpoint}/v2/invite/wallet/check/v2`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
wallet: address,
network: this.isTestnet ? 'ton-testnet' : 'ton-mainnet',
region: { countryCode },
}),
});
return response.json();
}
}
// Usage example
async function example() {
const holders = new HoldersIntegration(false); // Production
// Create a proof and connect the wallet
const result = await holders.enrollWallet(
Address.parse('EQ...'),
{ publicKey: Buffer.from('...'), secretKey: Buffer.from('...') },
'base64-state-init...',
'optional-invite-code'
);
if (result.success && result.token) {
// Save the token
localStorage.setItem('holders-token', result.token);
// Fetch the state
const state = await holders.getUserState(result.token);
console.log('User state:', state);
// Fetch the accounts
if (state.state === 'ok') {
const accounts = await holders.getAccounts(result.token);
console.log('Accounts:', accounts);
}
}
}
React Native: WebView component
import React, { useRef, useMemo, useCallback } from 'react';
import { View } from 'react-native';
import WebView, { WebViewMessageEvent } from 'react-native-webview';
interface HoldersWebViewProps {
token: string;
userState: any;
accounts?: any[];
onClose?: () => void;
isTestnet?: boolean;
}
export function HoldersWebView({
token,
userState,
accounts,
onClose,
isTestnet = false,
}: HoldersWebViewProps) {
const webViewRef = useRef<WebView>(null);
const baseUrl = isTestnet
? 'https://tonhub-stage.holders.io'
: 'https://tonhub.holders.io';
const source = useMemo(() => {
const params = new URLSearchParams({
lang: 'en',
currency: 'USD',
theme: 'holders',
'theme-style': 'dark',
'initial-route': '/accounts',
});
return { uri: `${baseUrl}?${params.toString()}` };
}, [baseUrl]);
// Initial state injection
const injectedJS = useMemo(() => {
const initialState = {
user: {
status: {
state: userState.state,
suspended: userState.suspended || false,
},
token,
},
accountsList: accounts || [],
};
return `
(function() {
window.initialState = ${JSON.stringify(initialState)};
})();
true;
`;
}, [token, userState, accounts]);
// Handling messages from the WebView
const handleMessage = useCallback((event: WebViewMessageEvent) => {
try {
const message = JSON.parse(event.nativeEvent.data);
switch (message.type) {
case 'closeApp':
onClose?.();
break;
case 'sendTransaction':
// Transaction handling
handleTransaction(message.payload);
break;
case 'loaded':
// WebView loaded
console.log('Holders WebView loaded');
break;
}
} catch (error) {
console.error('Failed to parse WebView message:', error);
}
}, [onClose]);
return (
<View style={{ flex: 1 }}>
<WebView
ref={webViewRef}
source={source}
injectedJavaScriptBeforeContentLoaded={injectedJS}
onMessage={handleMessage}
allowsInlineMediaPlayback
javaScriptEnabled
domStorageEnabled
/>
</View>
);
}
Interaction flowcharts
Full Holders workflow
.png?fit=max&auto=format&n=sYcwF1WqD1mDpark&q=85&s=4ea34aedeb560eeec405e3e47c52676a)
Handling transactions via TonConnect

Push notifications
The Holders platform implements a push notification system that informs users about key operations being performed. For each integration project, it is recommended to individually determine the optimal mechanism for delivering notifications to end users. For questions regarding the configuration of this functionality, please contact the Holders integration manager.Data storage
Recommended storage structureTypescript
interface HoldersStorage {
// JWT token by wallet address
getToken(address: string): string | null;
setToken(address: string, token: string): void;
deleteToken(address: string): void;
// Apple Pay credentials
getProvisioningCredentials(): ProvisioningCredential[];
setProvisioningCredentials(creds: ProvisioningCredential[]): void;
// Invite/referral data
getInviteId(): string | null;
setInviteId(id: string): void;
// Campaign parameters
getCampaignParams(): Record<string, string>;
setCampaignParams(params: Record<string, string>): void;
}