TIAN Points API
Integrate TIAN Points into your application to verify balances, deduct points for usage, and set up recurring monthly subscriptions. All endpoints are authenticated via API keys.
v3.33 — Partner onboarding checklist — each integration now shows a 5-step tracker (API key, client secret, redirect URI, webhook URL, first live deduction) directly in the Partners dashboard
Released 2026-04-10.
https://wallet.asktian.com/api/v1X-API-Key: tc_live_xxxdid:privy:xxxxx (Privy user ID)X-RateLimit-Remainingwallet.asktian.com is the central identity and payments layer for the askTIAN ecosystem. It handles user accounts, TIAN Points balances, and all credit transactions. Any askTIAN product (chat, API, predict, or third-party) can delegate authentication and billing to the wallet rather than maintaining its own user store.
Every askTIAN product shares the same Privy App ID, so users have one account across all properties. Your backend calls the wallet API to verify identity and charge credits — the wallet is the single source of truth for balances.
User (browser)
│
├─ Logs in via Privy (shared App ID across all askTIAN sites)
│ └─ Receives a short-lived access token (privyToken)
│
├─ Calls your product backend
│ └─ Sends { privyToken } in the request body
│
└─ Your backend calls wallet.asktian.com/api/v1
├─ POST /api/v1/auth/verify → verify identity + get balance + sessionToken
├─ POST /api/v1/points/deduct → charge the user (pass sessionToken)
└─ Returns feature result to userWhat is a Privy ID?
Every user has a unique Privy ID in the format did:privy:clxxxxxxxxxxxxxxxx. This is the user's permanent identifier across all askTIAN products. You never need to pass it directly — the wallet extracts it from the Privy JWT during /api/v1/auth/verify and returns it to you. Store it in your own DB if you need to associate wallet users with your own records.
Go to the Partners page and create a new integration. Give it a name that matches your product (e.g. askTIAN Chat). You will receive an API key in the format tc_live_xxxxxxxxxxxxxxxx.
Create a separate key per product (chat, api, predict) so you can track usage independently and rotate keys without affecting other services. Store each key in your backend environment — never expose it to the browser.
All askTIAN products share the same Privy App ID. Install the Privy React SDK and wrap your app in PrivyProvider using the shared App ID. When the user is logged in, call getAccessToken() to obtain a short-lived JWT before every backend call.
npm install @privy-io/react-auth// main.tsx — wrap your app once
import { PrivyProvider } from "@privy-io/react-auth";
<PrivyProvider
appId={import.meta.env.VITE_PRIVY_APP_ID} // same App ID as wallet.asktian.com
config={{
loginMethods: ["wallet", "email", "google", "twitter"],
appearance: { theme: "dark" },
}}
>
<App />
</PrivyProvider>// In any component that calls your backend
import { usePrivy } from "@privy-io/react-auth";
function useTianRequest() {
const { getAccessToken, authenticated } = usePrivy();
return async (endpoint: string, body: object = {}) => {
if (!authenticated) throw new Error("Not logged in");
// Always call getAccessToken() fresh — it auto-refreshes
const privyToken = await getAccessToken();
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body, privyToken }),
});
if (res.status === 402) {
// Redirect user to top up
window.location.href = "https://wallet.asktian.com/purchase";
return null;
}
return res.json();
};
}On every request that costs TIAN Points, your backend should:
- Call
/api/v1/auth/verifywith the Privy token to confirm identity, get the current balance, and receive asessionToken. - Check the balance is sufficient for the operation.
- Call
/api/v1/points/deductwith thesessionTokento charge the user before doing expensive work. The token is burned on use. - Serve the feature result.
Try it with curl first:
# ── Option A: Check balance only (no deduction) ────────────────────────────────────
# Use this to show the user their balance on page load without charging them.
# You need the privyId (from a previous /auth/verify call or your own DB).
curl "https://wallet.asktian.com/api/v1/points/balance?privyId=did:privy:clxxxxxxxxxxxxxxxx" \
-H "X-API-Key: tc_live_your_key_here"
# Response:
# { "privyId": "did:privy:clxxxxxxxxxxxxxxxx", "balance": 250 }
# ── Option B: Verify token + deduct (full flow) ───────────────────────────────────
# Use this when the user triggers a paid action.
# Step 1: Verify the Privy token → get privyId + balance + sessionToken
curl -X POST https://wallet.asktian.com/api/v1/auth/verify \
-H "Content-Type: application/json" \
-H "X-API-Key: tc_live_your_key_here" \
-d '{"privyToken": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."}'
# Response:
# {
# "privyId": "did:privy:clxxxxxxxxxxxxxxxx",
# "balance": 250,
# "sessionToken": "a3f8c2d1...",
# "sessionExpiresAt": "2026-03-27T12:05:00.000Z"
# }
# Step 2: Deduct points using the sessionToken from Step 1
curl -X POST https://wallet.asktian.com/api/v1/points/deduct \
-H "Content-Type: application/json" \
-H "X-API-Key: tc_live_your_key_here" \
-d '{"privyId": "did:privy:clxxxxxxxxxxxxxxxx", "sessionToken": "a3f8c2d1...", "amount": 1, "description": "Chat message"}'
# Response:
# { "success": true, "creditsDeducted": 1, "newBalance": 249 }
# ── Option C: Get a pre-built top-up URL (server-side, no frontend needed) ───────────
# Use this to generate a top-up link for email, Telegram, or any server-side notification.
# You need the privyId (from a previous /auth/verify call or your own DB).
curl "https://wallet.asktian.com/api/v1/topup-url?privyId=did:privy:clxxxxxxxxxxxxxxxx&amount=100&returnUrl=https%3A%2F%2Fyourapp.com%2Fdashboard" -H "X-API-Key: tc_live_your_key_here"
# Response:
# {
# "topupUrl": "https://wallet.asktian.com/purchase?method=card&amount=100&minBalance=100&returnUrl=https%3A%2F%2Fyourapp.com%2Fdashboard"
# }
# Open or share this URL directly — no further construction needed.Node.js / TypeScript helper:
// Node.js / TypeScript — reusable helper
const WALLET_BASE = "https://wallet.asktian.com";
const WALLET_API_KEY = process.env.WALLET_API_KEY!;
async function verifyAndDeduct(
privyToken: string,
amount: number,
description: string,
endpoint: string
) {
// Step 1: Verify token → get privyId, balance, and a single-use sessionToken
const authRes = await fetch(`${WALLET_BASE}/api/v1/auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": WALLET_API_KEY },
body: JSON.stringify({ privyToken }),
});
if (!authRes.ok) throw Object.assign(new Error("Auth failed"), { status: 401 });
const { privyId, balance, sessionToken } = await authRes.json();
// Step 2: Check balance
if (balance < amount) {
throw Object.assign(
new Error("Insufficient TIAN Points"),
{ status: 402, balance, required: amount }
);
}
// Step 3: Deduct — pass sessionToken to prevent replay attacks
const deductRes = await fetch(`${WALLET_BASE}/api/v1/points/deduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": WALLET_API_KEY },
body: JSON.stringify({ privyId, sessionToken, amount, description, endpoint }),
});
if (!deductRes.ok) throw Object.assign(new Error("Deduction failed"), { status: 402 });
const { newBalance } = await deductRes.json();
return { privyId, newBalance };
}
// Usage in an Express route:
app.post("/api/chat", async (req, res) => {
try {
const { privyToken, message } = req.body;
await verifyAndDeduct(privyToken, 1, "Chat message", "/api/chat");
const reply = await generateReply(message);
res.json({ reply });
} catch (err: any) {
res.status(err.status ?? 500).json({
error: err.message,
...(err.balance !== undefined && { balance: err.balance, required: err.required }),
});
}
});When your backend receives a 402 from the wallet, the response contains balance and required but not a topupUrl. Your backend should call GET /api/v1/topup-url to get a pre-built link, then forward it to your frontend. Alternatively, build the URL manually using these purchase page query parameters:
| Parameter | Values | Effect |
|---|---|---|
| method | card (default) or tian | Pre-selects the payment tab |
| amount | Integer ≥ 100 | Pre-fills the custom quantity field (card tab) |
| returnUrl | Any https:// URL | Shows a "Return to App" button after successful purchase |
| minBalance | Positive integer | Hides the "Return to App" button until the user's balance meets this threshold |
// Option A: your backend fetches topupUrl from /api/v1/topup-url and forwards it
// (recommended — the wallet builds the correct URL for the user's account)
if (res.status === 402) {
const { balance, required, topupUrl } = await res.json(); // topupUrl from your backend
window.open(topupUrl, "_blank");
}
// Option B: build the URL manually on the frontend (no extra backend call)
if (res.status === 402) {
const { balance, required } = await res.json();
const returnUrl = encodeURIComponent(window.location.href);
const topUpUrl =
`https://wallet.asktian.com/purchase?method=card&amount=${required}&returnUrl=${returnUrl}`;
window.open(topUpUrl, "_blank");
}The sessionToken returned by /auth/verify has a 5-minute TTL and is burned on first use. For flows where the user confirms before paying (e.g., "You will spend 10 TIAN Points — confirm?"), call /api/v1/auth/verify once when the page loads to show the balance, then pass thesessionToken to /api/v1/points/deduct only after the user confirms. If more than 5 minutes pass before confirmation, call /auth/verify again.
// Confirm-before-pay pattern
const authRes = await fetch(`${WALLET_BASE}/api/v1/auth/verify`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": WALLET_API_KEY },
body: JSON.stringify({ privyToken }),
});
const { privyId, balance, sessionToken } = await authRes.json();
// Show balance to user; hold sessionToken in memory
if (balance < cost) return res.status(402).json({ error: "Insufficient TIAN Points" });
// ... user confirms the action ...
// Deduct — sessionToken is burned here
const deductRes = await fetch(`${WALLET_BASE}/api/v1/points/deduct`, {
method: "POST",
headers: { "Content-Type": "application/json", "X-API-Key": WALLET_API_KEY },
body: JSON.stringify({ privyId, sessionToken, amount: cost, description }),
});
// If 401: sessionToken expired or reused — call /auth/verify againwallet.asktian.com is a Progressive Web App (v2.46+). Users on Android Chrome and iOS Safari can install it to their Home Screen and use it in standalone mode — no browser chrome, full-screen, with its own icon. This matters for your integration because the wallet purchase and top-up flows run inside the same PWA shell.
Install flow
An install-prompt banner appears automatically at the bottom of the screen on first visit. On Android Chrome the native beforeinstallprompt event is used; on iOS Safari the banner shows manual Share → "Add to Home Screen" instructions. The banner is dismissed once and the decision is persisted in localStorage.
Deep-linking from your app to the wallet on mobile
When your product redirects a mobile user to wallet.asktian.com/purchase, the wallet opens inside the installed PWA (if present) rather than the browser. Always pass returnUrl so the user can tap "Return to App" after topping up:
// Build the top-up deep-link — works in both browser and PWA
const returnUrl = encodeURIComponent(window.location.href);
const topUpUrl =
`https://wallet.asktian.com/purchase?method=card&amount=${required}&returnUrl=${returnUrl}`;
// On mobile: open in same tab so the PWA handles navigation
// On desktop: open in new tab to keep your app visible
const isMobile = /Mobi|Android/i.test(navigator.userAgent);
if (isMobile) {
window.location.href = topUpUrl;
} else {
window.open(topUpUrl, "_blank");
}Offline behaviour
The wallet service worker pre-caches all static assets (JS, CSS, HTML, icons) using Workbox. When a user navigates to the wallet while offline, a branded /offline.html fallback page is served. API calls (/api/*) are never intercepted by the service worker — they always go to the network. Your integration should handle network errors gracefully and show a user-friendly message when the wallet API is unreachable.
Viewport & safe-area insets
The wallet uses viewport-fit=coverand CSS env(safe-area-inset-*)utilities so content is never hidden behind the iPhone notch, Dynamic Island, or Android navigation bar. If you embed wallet pages in a WebView or iframe, ensure your container also sets viewport-fit=cover in its own meta viewport tag.
Touch targets & accessibility
All interactive elements in the wallet meet the WCAG 2.1 minimum of 44×44 px. A global .touch-target utility class (adds min-h-[44px] min-w-[44px]) is available if you build custom UI that embeds wallet flows. Tap-highlight is suppressed via -webkit-tap-highlight-color: transparentfor a native-app feel.
Performance on mobile networks
The wallet QueryClient is configured with staleTime: 60_000 and refetchOnWindowFocus: falseto minimise unnecessary API calls on mobile (where switching apps is frequent). All pages are lazy-loaded with React.lazy + Suspense, and heavy vendor bundles (recharts, tRPC, Radix UI) are split into separate chunks for better HTTP caching. The initial JS payload is therefore significantly smaller than a monolithic bundle.
If your product already uses Privy server-side (e.g. a tRPC procedure that receives ctx.user from a verified Privy JWT), you need to decide how to get the raw Privy access token to pass to /api/v1/auth/verify. There are two options:
Option A (recommended) — client passes privyToken in the request
Have the client call getAccessToken() from the Privy SDK and include the result as a field in the request body (e.g. privyToken). Your server receives it alongside the normal request and forwards it to /api/v1/auth/verify.
This is the correct pattern. The Privy access token is a short-lived JWT that the client already holds — it is designed to be forwarded to trusted backends. It does not expose any secret; it is equivalent to a session cookie. The wallet verifies its signature server-side using Privy's public key.
// tRPC router — add privyToken to the input schema
predictProcedure: protectedProcedure
.input(z.object({
query: z.string(),
privyToken: z.string(), // ← add this field
}))
.mutation(async ({ input, ctx }) => {
// ctx.user is already verified by your existing Privy middleware
// Now also verify with the wallet to get a sessionToken for deduction
const { balance, sessionToken } = await verifyWithWallet(input.privyToken);
if (balance < PREDICT_COST) {
throw new TRPCError({ code: "PAYMENT_REQUIRED", message: "Insufficient TIAN Points" });
}
await deductWithWallet(ctx.user.privyId, sessionToken, PREDICT_COST, "Prediction query");
return runPrediction(input.query);
}),
// Client — pass the token alongside the request
const { getAccessToken } = usePrivy();
const predict = trpc.predict.predictProcedure.useMutation();
const handlePredict = async (query: string) => {
const privyToken = await getAccessToken(); // fresh token, auto-refreshes
const result = await predict.mutateAsync({ query, privyToken });
};Option B — server re-fetches the token from Privy
The server uses the user's privyId (from ctx.user) to call Privy's server-to-server API and obtain a fresh access token on behalf of the user.
Do not use this. Privy's server-to-server token endpoint is designed for admin operations (user management, wallet actions) — not for generating user-scoped access tokens to forward to third-party services. It requires your Privy App Secret to be used in a way that increases exposure risk, and the resulting token may have different claims than a client-issued one. Option A is simpler, more reliable, and is the pattern the wallet API is designed for.
Summary: use Option A. Add privyToken: z.string() to your tRPC input schema, call getAccessToken() on the client before each paid mutation, and forward it to /api/v1/auth/verify on the server. One extra field, zero new infrastructure.
Paste your API key and a Privy access token below to call POST /api/v1/auth/verify live and see the raw JSON response. Get your API key from the Partners page. Get a Privy token by calling getAccessToken() in your app's browser console.
