REST-style JSON APIs, widget session tokens, and iframe embedding for the Puanlar wallet in your web application.
# Puanlar Wallet Integration Guide
This guide explains how to integrate the Puanlar centralized wallet into **any web application**. It is **not** tied to a specific host product: you register a client app, store each end user’s puanlarUserId in your own user model, call Puanlar’s HTTP APIs from your backend, and optionally embed the hosted wallet UI in an iframe.
Puanlar is a multi-tenant wallet platform: balances, ledger transactions, and Stripe top-ups live in Puanlar; your app orchestrates identity, linking, and business rules.
---
---
Puanlar provides:
puanlarUserId**: Identifier linking your app's user to a Puanlar user (not unique - one Puanlar account can be linked to multiple app users)---
┌─────────────────┐
│ Your App │
│ (Frontend) │
│ │
│ ┌───────────┐ │
│ │ Puanlar│ │
│ │ Widget │ │◄──────┐
│ │ (iframe) │ │ │
│ └───────────┘ │ │
│ │ │ │
└───────┼─────────┘ │
│ │
│ HTTP │ postMessage
│ │
┌───────┼─────────┐ │
│ Your App │ │
│ (Backend) │ │
│ │ │
│ ┌───────────┐ │ │
│ │ Puanlar│ │◄──────┘
│ │ Service │ │
│ └───────────┘ │
└───────┼─────────┘
│
│ HTTPS
│
┌───────┼─────────┐
│ Puanlar │
│ (Next.js App) │
│ │
│ • Widget APIs │
│ • Server APIs │
│ • Hosted auth │
│ (in widget) │
└─────────────────┘---
http://localhost:3000 (development)/admin/client-apps)clientId and clientSecretallowedOrigins (your frontend URL)allowedIframeParents (your frontend URL)scopes: wallet:read, wallet:writepuanlarUserId Field**puanlarUserId field (String, optional, sparse - NOT unique, to allow one Puanlar account for multiple users)---
Paths are relative to your Puanlar base URL (e.g. https://wallet.example.com).
For a normal integration you only use **(A) server-to-server wallet APIs** from your backend and **(B) widget session + widget data/top-up APIs** (session created on your server; browser talks to Puanlar with ?session=). End-user signup/login runs **inside the iframe** (or Puanlar pages), not via extra REST calls from your app—so auth, profile, admin, and JWT-wallet routes are omitted here.
**Client app setup:** Create the app and copy clientId / clientSecret in the Puanlar admin UI (/admin/client-apps). No need to call admin HTTP APIs for integration.
---
Authenticate with **clientId** + **clientSecret**. Never send the secret to the browser.
**GET (query string)**
| Method | Path | Query parameters | |--------|------|------------------| | GET | /api/server/wallet | clientId, clientSecret, puanlarUserId | | GET | /api/server/wallet/check-balance | clientId, clientSecret, puanlarUserId, requiredAmount |
**GET /api/server/wallet response:** { "balance", "transactions", "isActive" } **GET check-balance response:** { "balance", "requiredAmount", "hasSufficientBalance" } User unknown in Puanlar → 401, often with requiresAuth / code: USER_NOT_FOUND.
**POST (JSON body)**
| Method | Path | Body | |--------|------|------| | POST | /api/server/wallet/debit | clientId, clientSecret, puanlarUserId, amount, referenceType, referenceId, optional description | | POST | /api/server/wallet/credit | Same fields as debit |
**Success:** { "balance", "message" }. **Errors:** 401 bad client credentials; 404 user not found; 400 insufficient balance (debit).
Debit/credit are **idempotent** on the pair (referenceType, referenceId).
---
**1. Create session — call from your server**
| Method | Path | Body (JSON) | |--------|------|-------------| | POST | /api/widget/session | clientId, clientSecret, puanlarUserId, optional parentOrigin |
**Response:** { "sessionToken", "expiresAt" } (short-lived, typically ~5 minutes).
**2. Calls from the browser (widget uses these; your iframe loads hosted pages below)**
| Method | Path | Query / body | |--------|------|----------------| | GET | /api/widget/wallet | ?session=<token> | | POST | /api/widget/topup/create-intent | ?session=<token>, body { "amount": number } | | POST | /api/widget/topup/confirm | ?session=<token>, body { "paymentIntentId": string } |
**GET /api/widget/wallet:** balance, last **10** transactions, isActive, user: { firstName, lastName }.
**Iframe URLs (src)**
/widget/wallet?session=<token> — omit session to show embedded login/signup first/widget/topup?session=<token>---
Listen on window for message; in production, verify event.origin is your Puanlar host.
| event.data.type | Meaning | |-------------------|--------| | PUANLAR_WALLET_UPDATED | Refresh UI / reload iframe | | PUANLAR_WALLET_LOADED | Widget ready | | PUANLAR_OPEN_TOPUP | Open top-up (e.g. new window with /widget/topup?session=...) | | PUANLAR_AUTH_REQUIRED | User must complete Puanlar auth | | PUANLAR_AUTH_SUCCESS | Includes puanlarUserId — persist on your user, then create a new widget session | | PUANLAR_LOGOUT_REQUEST | Clear link / reload iframe without session if switching accounts |
---
Add to your .env file:
# Puanlar Configuration
PUANLAR_BASE_URL=http://localhost:3000
PUANLAR_CLIENT_ID=your_client_id_here
PUANLAR_CLIENT_SECRET=your_client_secret_hereAdd puanlarUserId field to your User schema:
// Example: MongoDB/Mongoose
const userSchema = new Schema({
// ... existing fields
puanlarUserId: {
type: String,
default: null,
sparse: true // NOT unique - allows one Puanlar account to be linked to multiple app users
}
});**Important**: The puanlarUserId field should NOT have a unique: true constraint if you want several of your app’s users to share one Puanlar wallet (e.g. family or team accounts). If you require strictly one app user per Puanlar user, you may enforce uniqueness in your own schema instead.
**Migration Note**: If you have an existing unique index on puanlarUserId, you need to drop it. See the migration section below.
Create a service file to communicate with Puanlar APIs:
**File: services/PUANLAR_service/Puanlar.service.js**
const axios = require('axios');
const PUANLAR_BASE_URL = process.env.PUANLAR_BASE_URL || 'http://localhost:3000';
const PUANLAR_CLIENT_ID = process.env.PUANLAR_CLIENT_ID;
const PUANLAR_CLIENT_SECRET = process.env.PUANLAR_CLIENT_SECRET;
/**
* Create widget session token for iframe embedding
*/
async function createWidgetSession(puanlarUserId, parentOrigin) {
try {
if (!PUANLAR_CLIENT_ID || !PUANLAR_CLIENT_SECRET) {
throw new Error('Puanlar client credentials not configured');
}
const response = await axios.post(
`${PUANLAR_BASE_URL}/api/widget/session`,
{
clientId: PUANLAR_CLIENT_ID,
clientSecret: PUANLAR_CLIENT_SECRET,
puanlarUserId,
parentOrigin,
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
}
);
return response.data;
} catch (error) {
if (error.response) {
const errorData = error.response.data || {};
const apiError = new Error(errorData.error || 'Puanlar API error');
if (errorData.requiresAuth) {
apiError.requiresAuth = true;
}
if (errorData.code) {
apiError.code = errorData.code;
}
throw apiError;
}
throw error;
}
}
/**
* Get wallet from Puanlar (server-to-server)
*/
async function getWallet(puanlarUserId) {
try {
const response = await axios.get(
`${PUANLAR_BASE_URL}/api/server/wallet`,
{
params: {
clientId: PUANLAR_CLIENT_ID,
clientSecret: PUANLAR_CLIENT_SECRET,
puanlarUserId,
},
timeout: 10000,
}
);
return response.data;
} catch (error) {
// Handle errors (preserve requiresAuth flag)
if (error.response) {
const errorData = error.response.data || {};
const apiError = new Error(errorData.error || 'Puanlar API error');
if (errorData.requiresAuth) {
apiError.requiresAuth = true;
}
throw apiError;
}
throw error;
}
}
/**
* Check wallet balance (server-to-server)
*/
async function checkWalletBalance(puanlarUserId, requiredAmount) {
try {
const response = await axios.get(
`${PUANLAR_BASE_URL}/api/server/wallet/check-balance`,
{
params: {
clientId: PUANLAR_CLIENT_ID,
clientSecret: PUANLAR_CLIENT_SECRET,
puanlarUserId,
requiredAmount,
},
timeout: 10000,
}
);
return response.data;
} catch (error) {
if (error.response) {
const errorData = error.response.data || {};
const apiError = new Error(errorData.error || 'Puanlar API error');
if (errorData.requiresAuth) {
apiError.requiresAuth = true;
}
throw apiError;
}
throw error;
}
}
/**
* Debit wallet (server-to-server)
*/
async function debitWallet(puanlarUserId, amount, referenceType, referenceId, description) {
try {
const response = await axios.post(
`${PUANLAR_BASE_URL}/api/server/wallet/debit`,
{
clientId: PUANLAR_CLIENT_ID,
clientSecret: PUANLAR_CLIENT_SECRET,
puanlarUserId,
amount,
referenceType,
referenceId,
description,
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
}
);
return response.data;
} catch (error) {
if (error.response) {
const errorData = error.response.data || {};
const apiError = new Error(errorData.error || 'Puanlar API error');
if (errorData.requiresAuth) {
apiError.requiresAuth = true;
}
throw apiError;
}
throw error;
}
}
/**
* Credit wallet (server-to-server)
*/
async function creditWallet(puanlarUserId, amount, referenceType, referenceId, description) {
try {
const response = await axios.post(
`${PUANLAR_BASE_URL}/api/server/wallet/credit`,
{
clientId: PUANLAR_CLIENT_ID,
clientSecret: PUANLAR_CLIENT_SECRET,
puanlarUserId,
amount,
referenceType,
referenceId,
description,
},
{
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
}
);
return response.data;
} catch (error) {
if (error.response) {
const errorData = error.response.data || {};
const apiError = new Error(errorData.error || 'Puanlar API error');
if (errorData.requiresAuth) {
apiError.requiresAuth = true;
}
throw apiError;
}
throw error;
}
}
module.exports = {
createWidgetSession,
getWallet,
checkWalletBalance,
debitWallet,
creditWallet,
};Expose **only** what your frontend needs. Paths are yours to choose; keep them behind your own session/auth.
Typical endpoints:
| Your route (example) | Puanlar call | Purpose | |---------------------|-----------------|---------| | POST /api/integrations/Puanlar/widget-session | POST /api/widget/session with clientId, clientSecret, puanlarUserId, parentOrigin | Short-lived iframe token | | POST /api/integrations/Puanlar/link-account | *(none — update your DB)* | Persist puanlarUserId after widget PUANLAR_AUTH_SUCCESS | | POST /api/integrations/Puanlar/unlink-account | *(none — update your DB)* | Clear link when user switches Puanlar accounts |
**Widget session handler (Express-style sketch)**
// POST /api/integrations/Puanlar/widget-session
router.post('/widget-session', authenticateUser, async (req, res) => {
let parentOrigin;
try {
parentOrigin =
req.headers.origin ||
(req.headers.referer ? new URL(req.headers.referer).origin : undefined);
} catch {
parentOrigin = undefined;
}
const appUser = await loadUser(req.user.id); // your ORM / DB
if (!appUser?.puanlarUserId) {
return res.status(400).json({
error: 'Puanlar account not linked',
requiresAuth: true,
});
}
try {
const session = await createWidgetSession(appUser.puanlarUserId, parentOrigin);
return res.json(session);
} catch (error) {
const status = error.requiresAuth || error.code === 'USER_NOT_FOUND' ? 400 : 500;
return res.status(status).json({
error: error.message || 'Failed to create widget session',
requiresAuth: !!error.requiresAuth,
code: error.code,
});
}
});**Link / unlink (sketch)**
// POST /api/integrations/Puanlar/link-account body: { puanlarUserId }
router.post('/link-account', authenticateUser, async (req, res) => {
const { puanlarUserId } = req.body;
if (!puanlarUserId) return res.status(400).json({ error: 'puanlarUserId is required' });
let user = await loadUser(req.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.puanlarUserId && user.puanlarUserId !== puanlarUserId) {
await clearPuanlarLink(req.user.id); // e.g. MongoDB $unset for sparse indexes
user = await loadUser(req.user.id);
}
user.puanlarUserId = puanlarUserId;
await saveUser(user);
return res.json({ success: true, puanlarUserId });
});
// POST /api/integrations/Puanlar/unlink-account
router.post('/unlink-account', authenticateUser, async (req, res) => {
const user = await loadUser(req.user.id);
const prev = user?.puanlarUserId;
await clearPuanlarLink(req.user.id);
return res.json({ success: true, unlinkedpuanlarUserId: prev });
});Use **let** (not const) for user if you reassign after refetch. Prefer **removing** the field ($unset in MongoDB) over setting null when using sparse unique indexes.
Where you previously debited a local wallet, call **POST /api/server/wallet/debit** (via your Puanlar.service helpers) with:
referenceType** for your domain (ORDER_PAYMENT, BOOKING_HOLD, etc.)referenceId per business attempt** (see Idempotency)Refunds or goodwill credits use **POST /api/server/wallet/credit** with their own referenceType / referenceId pair.
Read-only flows: **GET /api/server/wallet** and **GET /api/server/wallet/check-balance**.
---
The following is a **reference pattern** (React + Vite-style). Adapt fetch helpers, routing, and styling to your stack. The important parts are: (1) never put clientSecret in the browser, (2) load the iframe from your Puanlar base URL with a session from your backend, (3) handle postMessage from the widget.
# Puanlar deployment (iframe src + postMessage origin checks)
VITE_PUANLAR_URL=https://your-puanlar-host
# Your own backend base URL (must expose widget-session + link/unlink)
VITE_YOUR_API_BASE_URL=https://api.yourapp.comPoint these at the bridge routes you implemented under Backend integration:
const API = import.meta.env.VITE_YOUR_API_BASE_URL;
export const PUANLAR_WIDGET_SESSION_URL = `${API}/api/integrations/Puanlar/widget-session`;
export const PUANLAR_LINK_ACCOUNT_URL = `${API}/api/integrations/Puanlar/link-account`;
export const PUANLAR_UNLINK_ACCOUNT_URL = `${API}/api/integrations/Puanlar/unlink-account`;**Example file:** components/PuanlarWalletModal.jsx (name as you like)
import React, { useState, useEffect, useRef } from 'react';
import { MdClose } from 'react-icons/md';
import styles from './PuanlarWalletModal.module.css';
import doApi from '../../utils/doApi';
import {
PUANLAR_WIDGET_SESSION_URL,
PUANLAR_LINK_ACCOUNT_URL,
PUANLAR_UNLINK_ACCOUNT_URL,
} from '../../config/PuanlarEndpoints';
import toast from 'react-hot-toast';
const PUANLAR_BASE_URL = import.meta.env.VITE_PUANLAR_URL || 'http://localhost:3000';
const PuanlarWalletModal = ({ isOpen, onClose }) => {
const [sessionToken, setSessionToken] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const iframeRef = useRef(null);
useEffect(() => {
if (isOpen && !sessionToken) {
createWidgetSession();
}
// Listen for messages from widget
const handleMessage = async (event) => {
// In production, verify event.origin matches Puanlar domain
if (event.data.type === 'PUANLAR_WALLET_UPDATED') {
toast.success('Wallet updated');
// Reload iframe
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
} else if (event.data.type === 'PUANLAR_WALLET_LOADED') {
console.log('Wallet loaded:', event.data);
} else if (event.data.type === 'PUANLAR_OPEN_TOPUP') {
openTopupModal();
} else if (event.data.type === 'PUANLAR_AUTH_REQUIRED') {
toast.error('Please login to Puanlar to access your wallet');
} else if (event.data.type === 'PUANLAR_AUTH_SUCCESS') {
// User authenticated, link the account and retry session creation
if (event.data.puanlarUserId) {
try {
await linkPuanlarAccount(event.data.puanlarUserId);
await createWidgetSession();
} catch (error) {
console.error('Error in auth success flow:', error);
try {
await createWidgetSession();
} catch (sessionError) {
console.error('Error creating session after auth:', sessionError);
}
}
}
} else if (event.data.type === 'PUANLAR_LOGOUT_REQUEST') {
// User wants to logout and switch account
handleLogout();
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [isOpen, sessionToken]);
const linkPuanlarAccount = async (puanlarUserId) => {
try {
const [response, err] = await doApi({
url: PUANLAR_LINK_ACCOUNT_URL,
method: 'POST',
data: { puanlarUserId },
});
if (err) {
throw new Error(err.error || 'Failed to link account');
}
toast.success('Puanlar account linked successfully!');
} catch (error) {
console.error('Error linking account:', error);
toast.error(error.message || 'Failed to link account');
throw error;
}
};
const handleLogout = async () => {
try {
// Unlink the account from backend
const [response, err] = await doApi({
url: PUANLAR_UNLINK_ACCOUNT_URL,
method: 'POST',
});
if (err) {
console.error('Error unlinking account:', err);
// Still proceed with logout even if unlink fails
} else {
toast.success('Logged out from Puanlar. You can now login with a different account.');
}
// Clear session token and reload widget to show auth UI
setSessionToken(null);
setError('');
// Reload iframe to show login/signup form
if (iframeRef.current) {
iframeRef.current.src = `${PUANLAR_BASE_URL}/widget/wallet`;
}
} catch (error) {
console.error('Error during logout:', error);
// Still proceed with logout
setSessionToken(null);
setError('');
if (iframeRef.current) {
iframeRef.current.src = `${PUANLAR_BASE_URL}/widget/wallet`;
}
}
};
const createWidgetSession = async () => {
setLoading(true);
setError('');
try {
const [response, err] = await doApi({
url: PUANLAR_WIDGET_SESSION_URL,
method: 'POST',
setLoading,
});
if (err) {
// If auth required, load widget without session - it will show auth UI
if (err.requiresAuth || err.error?.includes('not linked')) {
setSessionToken(null);
setError('');
return;
}
throw new Error(err.error || 'Failed to create widget session');
}
if (!response || !response.sessionToken) {
throw new Error('Invalid response from server');
}
setSessionToken(response.sessionToken);
} catch (error) {
console.error('Error creating widget session:', error);
if (error.message?.includes('not linked')) {
setError('AUTH_REQUIRED');
} else {
setError(error.message || 'Failed to load wallet');
toast.error(error.message || 'Failed to load wallet');
}
} finally {
setLoading(false);
}
};
const openTopupModal = () => {
if (!sessionToken) return;
const topupUrl = `${PUANLAR_BASE_URL}/widget/topup?session=${sessionToken}`;
const topupWindow = window.open(
topupUrl,
'Puanlar Topup',
'width=500,height=700,resizable=yes,scrollbars=yes'
);
// Listen for messages from topup window
const handleTopupMessage = (event) => {
if (event.data.type === 'PUANLAR_WALLET_UPDATED') {
toast.success('Wallet topped up successfully!');
if (iframeRef.current) {
iframeRef.current.src = iframeRef.current.src;
}
topupWindow?.close();
window.removeEventListener('message', handleTopupMessage);
}
};
window.addEventListener('message', handleTopupMessage);
};
if (!isOpen) return null;
const widgetUrl = `${PUANLAR_BASE_URL}/widget/wallet${sessionToken ? `?session=${sessionToken}` : ''}`;
return (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.header}>
<div className={styles.title}>
<h2>Wallet</h2>
</div>
<button className={styles.closeButton} onClick={onClose}>
<MdClose size={20} />
</button>
</div>
{loading && (
<div className={styles.loading}>
Loading wallet...
</div>
)}
{error && error !== 'AUTH_REQUIRED' && (
<div className={styles.error}>
{error}
<button onClick={createWidgetSession} className={styles.retryButton}>
Retry
</button>
</div>
)}
{(widgetUrl || !sessionToken) && !loading && error !== 'AUTH_REQUIRED' && (
<iframe
ref={iframeRef}
src={widgetUrl}
className={styles.iframe}
title="Puanlar Wallet"
allow="payment *;"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
/>
)}
</div>
</div>
);
};
export default PuanlarWalletModal;**Example:** PuanlarWalletModal.module.css
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
max-width: 600px;
width: 100%;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px;
border-bottom: 1px solid #e5e7eb;
background: linear-gradient(135deg, #075e54 0%, #0a3b83 100%);
color: white;
flex-shrink: 0;
}
.title h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.closeButton {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s;
}
.closeButton:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.loading {
padding: 2rem;
text-align: center;
color: #666;
}
.error {
padding: 2rem;
text-align: center;
color: #dc2626;
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
}
.retryButton {
padding: 0.5rem 1rem;
background-color: #2563eb;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.retryButton:hover {
background-color: #1d4ed8;
}
.iframe {
width: 100%;
flex: 1;
border: none;
min-height: 500px;
}
@media (max-width: 768px) {
.modal {
max-width: 100%;
max-height: 100vh;
border-radius: 0;
}
.overlay {
padding: 0;
}
.iframe {
min-height: 400px;
}
}import PuanlarWalletModal from '../components/PuanlarWalletModal';
const [showWallet, setShowWallet] = useState(false);
<PuanlarWalletModal isOpen={showWallet} onClose={() => setShowWallet(false)} />;---
PUANLAR_AUTH_SUCCESS message with puanlarUserIdPOST /api/integrations/Puanlar/link-account) to persist puanlarUserIduser.puanlarUserId existspuanlarUserId is linked to your user, it persistspuanlarUserId (no uniqueness on that field)$unset operator to remove the field (not set to null) to avoid sparse index conflictsPuanlar treats **referenceType + referenceId** as an idempotency key for debits/credits. If your product allows users to **retry** the same checkout or payment flow, generate a **new** referenceId for each new attempt so the ledger records a new charge. Reusing the same pair returns the original outcome without deducting again.
Your own business rules (e.g. whether a merchant can re-accept a cancelled order) are **outside** Puanlar; wallet balance is always authoritative via Puanlar APIs.
---
ws_<uuid> (e.g., ws_a1b2c3d4e5f6...)1. User opens wallet modal
↓
2. Frontend requests session from backend
↓
3. Backend calls Puanlar API with clientId/clientSecret
↓
4. Puanlar validates credentials and user
↓
5. Puanlar creates session token (5min expiry)
↓
6. Frontend embeds widget with session token in URL
↓
7. Widget validates session token on each API call
↓
8. After 5 minutes, session expires
↓
9. User needs to reopen modal to get new session---
#### 1. Get Wallet
const wallet = await getPuanlarWallet(puanlarUserId);
// Returns: { balance, transactions, isActive }#### 2. Check Balance
const result = await checkPuanlarBalance(puanlarUserId, requiredAmount);
// Returns: { balance, requiredAmount, hasSufficientBalance }#### 3. Debit Wallet (Payment)
// IMPORTANT: new referenceId per new payable attempt (idempotency)
const uniqueReferenceId = `${orderId}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const result = await debitPuanlarWallet(
puanlarUserId,
amount,
'ORDER_PAYMENT', // your domain-specific referenceType
uniqueReferenceId, // new id per attempt
'Payment description'
);
// Returns: { balance, message } from HTTP API**Note**: Reusing the same referenceType + referenceId replays the first outcome (no second debit). See Idempotency, linking, and data model notes.
#### 4. Credit Wallet (Refund)
const result = await creditPuanlarWallet(
puanlarUserId,
amount,
'ORDER_REFUND',
`${orderId}_refund`, // distinct referenceId from original debit unless intentional idempotent replay
'Refund description'
);
// Returns: { balance, message }referenceType is a **string label** for your ledger. Puanlar does not enforce an enum for server APIs—use consistent names in your integration, for example:
ORDER_PAYMENT, SUBSCRIPTION_CHARGE, MARKETPLACE_ESCROWORDER_REFUND, CHARGEBACK_CREDITTOPUP, BONUS, etc. (often created inside Puanlar services)---
// Handle requiresAuth error
if (err.requiresAuth) {
// Show wallet modal - it will display auth UI
setShowWallet(true);
}
// Handle account not linked
if (err.code === 'ACCOUNT_NOT_LINKED') {
// Widget will show login/signup form
setShowWallet(true);
}
// Handle other errors
toast.error(err.message || 'An error occurred');try {
const result = await checkPuanlarBalance(puanlarUserId, amount);
return result;
} catch (error) {
// Preserve requiresAuth flag
if (error.requiresAuth) {
error.requiresAuth = true;
}
throw error;
}USER_NOT_FOUND: User doesn't exist in Puanlar (needs authentication)ACCOUNT_NOT_LINKED: User hasn't linked Puanlar accountINSUFFICIENT_BALANCE: Not enough balance for operationINVALID_CLIENT: Client credentials invalidSESSION_EXPIRED: Widget session token expired---
clientSecret in frontend codeallowedOrigins and allowedIframeParents in Puanlarevent.origin matches Puanlar domainconst handleMessage = (event) => {
const PUANLAR_ORIGIN = 'https://puanlar.com';
if (event.origin !== PUANLAR_ORIGIN) {
return; // Ignore messages from untrusted origins
}
// Process message
};puanlarUserId should be treated as sensitivepuanlarUserId in frontend logs or URLs---
If puanlarUserId is optional and you use a **sparse unique** index, avoid setting the field to null when unlinking—use **$unset** so the index does not see duplicate nulls. That pattern is common when storing the link on a users collection.
If your product needs it, omit a uniqueness constraint on puanlarUserId in **your** database so multiple rows can hold the same value. If you previously had unique: true on that field, drop that index in a migration (for example db.users.dropIndex("puanlarUserId_1") in MongoDB).
const uniqueReferenceId = `${businessId}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;Use a **new** referenceId whenever the user starts a **new** payable attempt. Reuse the same pair only when you intentionally want the API to return the same result without double-charging (e.g. safe retries of the same HTTP request).
The embedded wallet can send PUANLAR_LOGOUT_REQUEST. Clear the stored puanlarUserId for the current app user (or only in session, depending on your policy), call your unlink API if you use one, then reload the iframe **without** a session token so the user can authenticate as a different Puanlar user.
---
PUANLAR_BASE_URL, PUANLAR_CLIENT_ID, PUANLAR_CLIENT_SECRET)puanlarUserId (or equivalent) to your user/account model; decide on uniqueness vs sharingPOST /api/server/wallet/debit and .../credit with correct idempotency keysrequiresAuth / USER_NOT_FOUND to your product UXPUANLAR_LOGOUT_REQUEST on the client if you support account switchingVITE_PUANLAR_URL or equivalent)/widget/wallet (and optional /widget/topup) with session query param from your APIpostMessage handling and strict event.origin checks in productionallowedOrigins (your frontend URL)allowedIframeParents (your frontend URL)wallet:read, wallet:writeclientId and clientSecret---
/admin/client-apps on your Puanlar deploymentPuanlar/README.md; architecture: Puanlar/docs/---
For issues or questions:
---
**Last updated**: March 2025 **Version**: 1.1.0 (general integration + full API reference)
