Puanlar
Puanlar / API reference

API Reference

REST-style JSON APIs, widget session tokens, and iframe embedding for the Puanlar wallet in your web application.

Introduction

# 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.

---

Table of Contents

---

Overview

Puanlar provides:

  • **Centralized wallet management**: All wallet balances and transactions are stored in Puanlar
  • **Stripe integration**: Secure payment processing for wallet top-ups
  • **Widget-based UI**: Embedded wallet interface via iframe
  • **Server-to-server APIs**: Backend operations for wallet transactions
  • **User account linking**: Connect your app's users to Puanlar accounts

Key Concepts

  • **puanlarUserId**: Identifier linking your app's user to a Puanlar user (not unique - one Puanlar account can be linked to multiple app users)
  • **Widget Session Token**: Short-lived token (5 minutes) for secure iframe embedding
  • **Client App**: Your application registered in Puanlar with credentials for API access

---

Architecture

code
┌─────────────────┐
│  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)  │
└─────────────────┘

---

Prerequisites

  • **Puanlar Application Running**
  • Puanlar must be deployed and accessible
  • Default: http://localhost:3000 (development)
  • **Client App Registration in Puanlar**
  • Register your app in Puanlar admin panel (/admin/client-apps)
  • Obtain clientId and clientSecret
  • Configure allowedOrigins (your frontend URL)
  • Configure allowedIframeParents (your frontend URL)
  • Set scopes: wallet:read, wallet:write
  • **User Model with puanlarUserId Field**
  • Your user model must include a puanlarUserId field (String, optional, sparse - NOT unique, to allow one Puanlar account for multiple users)

---

Puanlar HTTP API reference

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.

---

A. Server-to-server wallet (your backend only)

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).

---

B. Widget (iframe)

**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>

---

postMessage (widget ↔ your page)

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 |

---

Backend Integration

1. Environment Variables

Add to your .env file:

env
# Puanlar Configuration
PUANLAR_BASE_URL=http://localhost:3000
PUANLAR_CLIENT_ID=your_client_id_here
PUANLAR_CLIENT_SECRET=your_client_secret_here

2. User Model Update

Add puanlarUserId field to your User schema:

javascript
// 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.

3. Puanlar Service

Create a service file to communicate with Puanlar APIs:

**File: services/PUANLAR_service/Puanlar.service.js**

javascript
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,
};

4. Backend routes (bridge to Puanlar)

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)**

javascript
// 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)**

javascript
// 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.

5. Using server APIs from your domain logic

Where you previously debited a local wallet, call **POST /api/server/wallet/debit** (via your Puanlar.service helpers) with:

  • A stable **referenceType** for your domain (ORDER_PAYMENT, BOOKING_HOLD, etc.)
  • A **unique 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**.

---

Frontend Integration

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.

1. Environment variables

env
# 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.com

2. API constants (your backend, not Puanlar)

Point these at the bridge routes you implemented under Backend integration:

javascript
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`;

3. Example: wallet modal + iframe

**Example file:** components/PuanlarWalletModal.jsx (name as you like)

javascript
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;

4. Styles (example)

**Example:** PuanlarWalletModal.module.css

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;
  }
}

5. Mount the modal

javascript
import PuanlarWalletModal from '../components/PuanlarWalletModal';

const [showWallet, setShowWallet] = useState(false);

<PuanlarWalletModal isOpen={showWallet} onClose={() => setShowWallet(false)} />;

---

User Authentication Flow

Initial State

  • User registers/logs into your app (no Puanlar account yet)
  • User clicks "Wallet" or attempts a payment
  • Widget opens without session token
  • Widget shows login/signup form embedded in iframe

First-Time Authentication

  • User creates account in Puanlar widget (or logs in if already exists)
  • Widget sends PUANLAR_AUTH_SUCCESS message with puanlarUserId
  • Frontend calls your backend link endpoint (e.g. POST /api/integrations/Puanlar/link-account) to persist puanlarUserId
  • Frontend retries widget session creation
  • Widget loads successfully with session token

Subsequent Uses

  • User clicks "Wallet"
  • Frontend requests widget session from backend
  • Backend checks if user.puanlarUserId exists
  • If exists, creates session token and returns it
  • Widget loads with session token
  • User can view wallet, top up, see transactions

Account Linking

  • **One-time process**: Once puanlarUserId is linked to your user, it persists
  • **Persistent**: Link is stored in your database, survives logout/login
  • **Secure**: Linking happens server-side after user authenticates in Puanlar
  • **Multiple app users per Puanlar account**: If your schema allows it, many rows in your DB can share the same puanlarUserId (no uniqueness on that field)
  • **Re-linking**: Users can switch to a different Puanlar account by using the logout button in the wallet widget
  • **Logout**: Users can logout from their current Puanlar account and login with a different account
  • **Important**: When unlinking, use MongoDB's $unset operator to remove the field (not set to null) to avoid sparse index conflicts

Retries and idempotency (host app design)

Puanlar 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.

---

Session Management

Widget Session Token

  • **Lifetime**: 5 minutes
  • **Format**: ws_<uuid> (e.g., ws_a1b2c3d4e5f6...)
  • **Storage**: Database (Puanlar) with TTL index
  • **Purpose**: Secure iframe embedding without exposing JWT tokens

Session Flow

code
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

Session Expiry Handling

  • Sessions expire after 5 minutes
  • Widget shows error when session expires
  • User must close and reopen wallet modal to get new session
  • Frontend should handle expired sessions gracefully

---

Wallet Operations

Frontend Operations (via Widget)

  • **View Balance**: Displayed in widget
  • **View Transactions**: Displayed in widget
  • **Top Up**: Opens topup modal, processes Stripe payment

Backend Operations (Server-to-Server)

#### 1. Get Wallet

javascript
const wallet = await getPuanlarWallet(puanlarUserId);
// Returns: { balance, transactions, isActive }

#### 2. Check Balance

javascript
const result = await checkPuanlarBalance(puanlarUserId, requiredAmount);
// Returns: { balance, requiredAmount, hasSufficientBalance }

#### 3. Debit Wallet (Payment)

javascript
// 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)

javascript
const result = await creditPuanlarWallet(
  puanlarUserId,
  amount,
  'ORDER_REFUND',
  `${orderId}_refund`, // distinct referenceId from original debit unless intentional idempotent replay
  'Refund description'
);
// Returns: { balance, message }

Reference types

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_ESCROW
  • ORDER_REFUND, CHARGEBACK_CREDIT
  • Internal flows may use TOPUP, BONUS, etc. (often created inside Puanlar services)

---

Error Handling

Frontend Error Handling

javascript
// 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');

Backend Error Handling

javascript
try {
  const result = await checkPuanlarBalance(puanlarUserId, amount);
  return result;
} catch (error) {
  // Preserve requiresAuth flag
  if (error.requiresAuth) {
    error.requiresAuth = true;
  }
  throw error;
}

Common Error Codes

  • USER_NOT_FOUND: User doesn't exist in Puanlar (needs authentication)
  • ACCOUNT_NOT_LINKED: User hasn't linked Puanlar account
  • INSUFFICIENT_BALANCE: Not enough balance for operation
  • INVALID_CLIENT: Client credentials invalid
  • SESSION_EXPIRED: Widget session token expired

---

Security Considerations

1. Client Credentials

  • **Never expose** clientSecret in frontend code
  • Store credentials in backend environment variables only
  • Rotate secrets periodically via Puanlar admin panel

2. Origin Validation

  • Configure allowedOrigins and allowedIframeParents in Puanlar
  • Widget validates parent origin to prevent unauthorized embedding
  • In production, use HTTPS and exact domain matching

3. Session Token Security

  • Session tokens are short-lived (5 minutes)
  • Tokens are unique and single-use per session
  • Tokens are validated on every widget API call

4. postMessage Security

  • In production, verify event.origin matches Puanlar domain
  • Only process messages from trusted origins
  • Example:
javascript
const handleMessage = (event) => {
  const PUANLAR_ORIGIN = 'https://puanlar.com';
  if (event.origin !== PUANLAR_ORIGIN) {
    return; // Ignore messages from untrusted origins
  }
  // Process message
};

5. User Data

  • puanlarUserId should be treated as sensitive
  • Never expose puanlarUserId in frontend logs or URLs
  • Validate user ownership before operations

---

Idempotency, linking, and data model notes

MongoDB: sparse / unique indexes on `puanlarUserId`

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.

Sharing one Puanlar wallet across several app accounts

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).

Idempotent debits and user-visible retries

javascript
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).

Widget logout / account switching

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.

---

Complete Integration Checklist

Backend

  • [ ] Add environment variables (PUANLAR_BASE_URL, PUANLAR_CLIENT_ID, PUANLAR_CLIENT_SECRET)
  • [ ] Add puanlarUserId (or equivalent) to your user/account model; decide on uniqueness vs sharing
  • [ ] Drop or adjust DB indexes that conflict with optional / shared links
  • [ ] Implement a small server module calling Puanlar **server** and **widget session** APIs
  • [ ] Expose authenticated routes: widget session, link account, unlink account (paths of your choice)
  • [ ] Route all spend/refund flows through POST /api/server/wallet/debit and .../credit with correct idempotency keys
  • [ ] Map requiresAuth / USER_NOT_FOUND to your product UX
  • [ ] Handle PUANLAR_LOGOUT_REQUEST on the client if you support account switching

Frontend

  • [ ] Configure public URL of Puanlar (VITE_PUANLAR_URL or equivalent)
  • [ ] Point the app at **your** backend bridge URLs (session + link + unlink)
  • [ ] Embed /widget/wallet (and optional /widget/topup) with session query param from your API
  • [ ] Implement postMessage handling and strict event.origin checks in production
  • [ ] Test auth, link, unlink, top-up, and session expiry

Puanlar Setup

  • [ ] Register client app in Puanlar admin panel
  • [ ] Configure allowedOrigins (your frontend URL)
  • [ ] Configure allowedIframeParents (your frontend URL)
  • [ ] Set scopes: wallet:read, wallet:write
  • [ ] Save clientId and clientSecret
  • [ ] Test widget session creation

Testing

  • [ ] Test first-time user flow (no Puanlar account)
  • [ ] Test account linking flow
  • [ ] Test wallet display after linking
  • [ ] Test logout and re-linking with different account
  • [ ] Test top-up flow
  • [ ] Test payment deduction
  • [ ] Test refund credit
  • [ ] Test session expiry handling
  • [ ] Test error scenarios

---

Additional resources

  • **This file** — integration-only endpoints: Puanlar HTTP API reference
  • **Admin UI** — register client apps: /admin/client-apps on your Puanlar deployment
  • **Full API list** — Puanlar/README.md; architecture: Puanlar/docs/

---

Support

For issues or questions:

  • Check error messages and logs
  • Verify environment variables are set correctly
  • Verify client app is registered and active in Puanlar
  • Verify user has linked Puanlar account
  • Check network connectivity to Puanlar

---

**Last updated**: March 2025 **Version**: 1.1.0 (general integration + full API reference)