Build an App
This guide takes you from an empty folder to a completed Pi payment. The frontend is plain
HTML and JavaScript using the Pi SDK (window.Pi from https://sdk.minepi.com/pi-sdk.js);
the backend is a few Express routes that call the
Platform API. The same handshake works in any backend language.
By the end you will have an app that authenticates a Pioneer, verifies their identity server-side, and processes a User-to-App payment.
Before you start: set up your accounts, register your app, and copy your Server API Key from the Developer Portal. That key stays on your server.
Step 1: Set up the project
Create the project and install Express:
mkdir my-pi-app && cd my-pi-app
npm init -y
npm install express
You should end up with this layout:
my-pi-app/
├── public/
│ ├── index.html
│ └── app.js
├── server.js
└── .env # PI_API_KEY=your_key_here
Put your Server API Key in .env and never commit that file:
PI_API_KEY=your_key_here
Then create server.js. This serves the frontend from public/ and gives you somewhere to
add the payment routes in later steps:
const express = require('express');
const app = express();
app.use(express.json());
app.use(express.static('public'));
const PI_API_BASE = 'https://api.minepi.com/v2';
const PI_API_KEY = process.env.PI_API_KEY;
// Backend routes from Steps 3 and 5 go here.
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Run it with Node's built-in env file support:
node --env-file=.env server.js
Step 2: Load and initialize the SDK
Every Pi app must include the SDK script and call Pi.init() before any other SDK method.
Include it in every environment, including production — the Pi Browser does not inject
window.Pi for you.
Create public/index.html:
<!DOCTYPE html>
<html>
<head>
<title>My Pi App</title>
<script src="https://sdk.minepi.com/pi-sdk.js"></script>
</head>
<body>
<button id="login">Sign in with Pi</button>
<button id="pay" disabled>Pay 1 Pi</button>
<p id="status"></p>
<script src="/app.js"></script>
</body>
</html>
Then start public/app.js by initializing the SDK:
Pi.init({ version: "2.0", sandbox: true }); // use sandbox: false in production
Step 3: Authenticate a Pioneer
Request the payments scope alongside username so the same session can pay later. The
second argument handles payments that were interrupted before completion.
Add to public/app.js:
let accessToken = null;
document.getElementById('login').onclick = async () => {
try {
const auth = await window.Pi.authenticate(
['username', 'payments'],
onIncompletePayment
);
accessToken = auth.accessToken;
await verifyOnServer(accessToken);
document.getElementById('status').innerText = `Hello, ${auth.user.username}`;
document.getElementById('pay').disabled = false;
} catch (err) {
console.error('Auth failed', err);
}
};
async function verifyOnServer(token) {
const res = await fetch('/api/verify', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Server could not verify this user');
return res.json();
}
function onIncompletePayment(payment) {
return fetch('/api/payments/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId: payment.identifier,
txid: payment.transaction?.txid,
}),
});
}
Available scopes are username, payments, wallet_address, and in_app_notifications.
Request only what you use. For session handling patterns, see
Authentication.
Why
onIncompletePaymentsends no access tokenThis callback fires during
Pi.authenticate(), before the promise resolves — soaccessTokenis stillnulland cannot be attached. Your/api/payments/completeroute therefore cannot rely on a user session for this path. Authorize it on the payment instead: look the payment up withGET /payments/{payment_id}using your Server API Key and confirm it belongs to your app before completing it. The callbacks in Step 5 do have a token, so they send one.
Step 4: Verify the user on your backend
The user object returned to the frontend is for presentation only — a malicious client can
tamper with it. Your server confirms identity against the Platform API.
Add to server.js:
app.post('/api/verify', async (req, res) => {
const accessToken = req.headers.authorization?.replace('Bearer ', '');
const piRes = await fetch(`${PI_API_BASE}/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!piRes.ok) return res.status(401).json({ error: 'Unauthorized' });
const user = await piRes.json();
// This is where you would create your own session or JWT for `user.uid`.
res.json({ uid: user.uid, username: user.username });
});
Note the two authorization schemes: Bearer <accessToken> for user-scoped endpoints like
/me, and Key <PI_API_KEY> for server-only endpoints. Mixing them up is the most common
source of 401s.
Step 5: Create a payment
Add to public/app.js:
document.getElementById('pay').onclick = () => {
window.Pi.createPayment(
{ amount: 1, memo: 'Unlock Premium', metadata: { feature: 'premium' } },
{
onReadyForServerApproval: async (paymentId) => {
await fetch('/api/payments/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ paymentId }),
});
},
onReadyForServerCompletion: async (paymentId, txid) => {
const res = await fetch('/api/payments/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ paymentId, txid }),
});
if (res.ok) {
document.getElementById('status').innerText = 'Payment complete';
}
},
onCancel: (paymentId) => console.log('Cancelled:', paymentId),
onError: (error, payment) => console.error('Payment error:', error, payment),
}
);
};
The memo appears on the user's confirmation screen. Use metadata to link the payment to
your own records.
Step 6: Approve and complete on your backend
A Pi payment needs two server-side confirmations. Approval unlocks the blockchain transaction; completion closes the flow.
Add to server.js:
app.post('/api/payments/approve', async (req, res) => {
const { paymentId } = req.body;
// Confirm the order and price against your own database before approving.
const approval = await fetch(`${PI_API_BASE}/payments/${paymentId}/approve`, {
method: 'POST',
headers: { Authorization: `Key ${PI_API_KEY}` },
});
res.status(approval.status).json(await approval.json());
});
app.post('/api/payments/complete', async (req, res) => {
const { paymentId, txid } = req.body;
const completion = await fetch(`${PI_API_BASE}/payments/${paymentId}/complete`, {
method: 'POST',
headers: {
Authorization: `Key ${PI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ txid }),
});
const data = await completion.json();
if (completion.ok) {
// Deliver the purchased feature here, once, using verified payment data.
}
res.status(completion.status).json(data);
});
Both callbacks retry roughly every 10 seconds until their timer expires, so these routes will be called more than once for the same payment. Make them idempotent — guard delivery on payment state in your own database, not on the request arriving. See Developer Payment Flow for the full lifecycle.
Step 7: Test it
Keep sandbox: true and run your app through the Pi Sandbox, which works in a desktop
browser instead of on your phone. Complete one full payment there before deploying — a
payment that approves and completes in the Sandbox behaves the same in the Pi Browser.
See Using Pi Sandbox for Development for setup, and Pi Browser Constraints for platform limits that affect your app.
Step 8: Go to production
Set sandbox: false, deploy, register your production URL, and prove you own the domain.
Production App Access covers all three, and ends with the real User-to-App
payment that confirms your setup.
Next steps
- Authentication — session management and access tokens
- Payments — the full payment guide
- Advanced Payments — App-to-User payouts
- Ads — interstitial and rewarded ads
- Common Mistakes — the failure modes to avoid