Build a SaaS Platform with User Wallets
This tutorial shows you how to build a multi-tenant SaaS platform where each of your users gets their own wallet — they can collect payments from their customers, and your platform automatically earns a commission on every transaction.
Time: ~45 minutes Prerequisites: Complete Your First Sub-Merchant, Your First Collection, and Setup Webhooks.
What you'll build
A platform where:
- Each user is a merchant with their own NextAPI account
- Users generate QRPH codes for their own payment collection
- When a user collects a payment, your platform automatically takes a commission
- Users can request a payout of their balance to their bank
Architecture
User signs up on your platform
↓
Your platform creates: Merchant + Account (via NextAPI)
↓
User generates QRPH code (via NextAPI Funding Method)
↓
User's customer scans and pays
↓
NextAPI webhook → your platform receives it
↓
Your platform splits: net → user account, commission → platform account
↓
User requests payout → your platform calls POST /v2/payout-requests
Step 1: Merchant onboarding
When a user registers on your platform, automatically create their NextAPI merchant and account:
async function onboardUser(user) {
const auth = 'Basic ' + Buffer.from(
`${process.env.NEXTAPI_CLIENT_ID}:${process.env.NEXTAPI_CLIENT_SECRET}`
).toString('base64');
// Create merchant
const merchantRes = await fetch('https://api.partners.nextpay.world/v2/merchants', {
method: 'POST',
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
name: user.businessName,
external_id: `user-${user.id}`,
contact_email: user.email,
}),
});
const merchant = await merchantRes.json();
// Create account
const accountRes = await fetch('https://api.partners.nextpay.world/v2/accounts', {
method: 'POST',
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
merchant_id: merchant.id,
name: 'Main',
external_id: `account-user-${user.id}`,
}),
});
const account = await accountRes.json();
// Generate permanent QR code
const qrRes = await fetch(
`https://api.partners.nextpay.world/v2/accounts/${account.id}/funding-methods`,
{
method: 'POST',
headers: { 'Authorization': auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'qrph', external_id: `qr-user-${user.id}` }),
}
);
const fundingMethod = await qrRes.json();
// Store in your database
await db.users.update(user.id, {
nextapiMerchantId: merchant.id,
nextapiAccountId: account.id,
nextapiFundingMethodId: fundingMethod.id,
qrImageUrl: fundingMethod.qr_image_url,
});
return { merchant, account, fundingMethod };
}
Step 2: Show users their QR code
Each user's QR code is available from the stored qr_image_url:
app.get('/dashboard/qr', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.id);
res.json({
qrImageUrl: user.qrImageUrl,
accountId: user.nextapiAccountId,
});
});
In your dashboard UI:
<div class="payment-qr">
<h3>Your Payment QR Code</h3>
<img src="{{ user.qrImageUrl }}" alt="Scan to pay {{ user.businessName }}" width="256" height="256" />
<p>Customers scan this with GCash, Maya, or any bank app</p>
</div>
Step 3: Handle payments and take commission
When a user's customer pays, you receive a webhook. At this point, apply your commission split:
const PLATFORM_COMMISSION_RATE = 0.02; // 2%
const PLATFORM_ACCOUNT_ID = process.env.NEXTAPI_PLATFORM_ACCOUNT_ID;
app.post('/webhooks/nextapi', express.raw({ type: 'application/json' }), async (req, res) => {
// Verify signature (always first)
const signature = req.headers['x-nextpay-signature'];
if (!verifySignature(req.body, signature, process.env.NEXTAPI_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
const event = JSON.parse(req.body.toString());
if (event.type === 'funding_method.paid') {
const { account_id, amount, id: eventId } = event.data;
// Idempotency check
if (await isEventProcessed(eventId)) {
return res.status(200).send('OK');
}
// Look up the user who owns this account
const user = await db.users.findByNextapiAccountId(account_id);
if (!user) {
console.error(`No user found for account ${account_id}`);
return res.status(200).send('OK');
}
// Calculate commission
const commissionCentavos = Math.floor(amount * PLATFORM_COMMISSION_RATE);
if (commissionCentavos > 0) {
// Transfer commission from user account to platform account
await fetch(
`https://api.partners.nextpay.world/v2/accounts/${account_id}/transfer`,
{
method: 'POST',
headers: {
'Authorization': 'Basic ...',
'Content-Type': 'application/json',
'X-Idempotency-Key': `commission-${eventId}`,
},
body: JSON.stringify({
destination_account_id: PLATFORM_ACCOUNT_ID,
amount: commissionCentavos,
description: `Platform commission - ${eventId}`,
reference: `commission-${eventId}`,
}),
}
);
}
// Record in your database
await db.transactions.create({
userId: user.id,
type: 'collection',
grossAmount: amount,
commissionAmount: commissionCentavos,
netAmount: amount - commissionCentavos,
eventId,
createdAt: new Date(),
});
await markEventProcessed(eventId);
console.log(`User ${user.id} collected PHP ${amount / 100}, commission: PHP ${commissionCentavos / 100}`);
}
res.status(200).send('OK');
});
Step 4: Show user balance
Display the user's current balance from NextAPI:
app.get('/dashboard/balance', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.id);
const response = await fetch(
`https://api.partners.nextpay.world/v2/accounts/${user.nextapiAccountId}/balances`,
{
headers: { 'Authorization': 'Basic ...' },
}
);
const balance = await response.json();
res.json({
available: balance.available,
availableFormatted: `PHP ${(balance.available / 100).toFixed(2)}`,
pending: balance.pending,
});
});
Step 5: Handle user payout requests
When a user wants to withdraw their balance to their bank:
app.post('/dashboard/withdraw', requireAuth, async (req, res) => {
const { amountCentavos, bankCode, accountNumber, accountName } = req.body;
const user = await db.users.findById(req.user.id);
// Verify sufficient balance
const balanceRes = await fetch(
`https://api.partners.nextpay.world/v2/accounts/${user.nextapiAccountId}/balances`,
{ headers: { 'Authorization': 'Basic ...' } }
);
const balance = await balanceRes.json();
if (balance.available < amountCentavos) {
return res.status(400).json({ error: 'Insufficient balance' });
}
const withdrawalId = `withdrawal-${user.id}-${Date.now()}`;
const payoutRes = await fetch('https://api.partners.nextpay.world/v2/payout-requests', {
method: 'POST',
headers: {
'Authorization': 'Basic ...',
'Content-Type': 'application/json',
'X-Idempotency-Key': `${withdrawalId}-v1`,
},
body: JSON.stringify({
account_id: user.nextapiAccountId,
amount: amountCentavos,
recipient: {
type: 'bank_account',
bank_code: bankCode,
account_number: accountNumber,
account_name: accountName,
},
description: `Withdrawal - ${user.businessName}`,
reference: withdrawalId,
}),
});
const payoutRequest = await payoutRes.json();
// Store withdrawal record
await db.withdrawals.create({
userId: user.id,
payoutRequestId: payoutRequest.id,
amount: amountCentavos,
status: 'initiated',
reference: withdrawalId,
});
res.json({
withdrawalId,
payoutRequestId: payoutRequest.id,
status: payoutRequest.status,
message: 'Withdrawal initiated. Funds will arrive within 30 seconds (InstaPay) or by end of day (PESONet).',
});
});
Complete flow summary
| Step | What happens | Your code |
|---|---|---|
| User signs up | Create Merchant + Account + Funding Method | onboardUser() |
| User's customer pays | NextAPI fires funding_method.paid | Webhook handler |
| Commission split | Transfer from user account to platform account | POST /accounts/{id}/transfer |
| User checks balance | Show available from NextAPI | GET /accounts/{id}/balances |
| User withdraws | Create payout request | POST /payout-requests |
| Withdrawal confirmed | NextAPI fires payout_request.completed | Webhook handler |
What's next?
- Take Commission from Sales — deeper coverage of commission models
- Transfer Between Accounts — internal fund movement
- Reconcile Transactions — audit trail and reconciliation
- Handle Payout Failures — when user withdrawals fail