Common Pi SDK Integration Mistakes
This page documents the most frequently observed mistakes when integrating the Pi SDK, based on review of multiple implementations. Avoid these pitfalls to ensure a correct, secure integration.
Mistake 1: Missing pi-sdk.js Script Tag and Pi.init() Call
The Problem
Every Pi app must load the Pi SDK script and call Pi.init() before using any Pi SDK methods. Without these steps, window.Pi is undefined and all SDK calls will throw a runtime error.
The Pi Browser does not automatically inject window.Pi without the script tag.
Wrong
<!-- index.html — missing the Pi SDK script tag -->
<head>
<title>My Pi App</title>
<!-- No pi-sdk.js here! window.Pi will be undefined. -->
</head>
// Calling Pi SDK methods without Pi.init() first
window.Pi.authenticate(['username', 'payments'], onIncompletePaymentFound);
// ERROR: Pi.init() was never called
Correct
<!-- index.html -->
<head>
<title>My Pi App</title>
<!-- REQUIRED: Load the Pi SDK before any Pi SDK calls -->
<script src="https://sdk.minepi.com/pi-sdk.js"></script>
</head>
// In main.tsx or App entry point — before authenticate() or createPayment()
window.Pi.init({ version: "2.0", sandbox: true }); // use sandbox: false in production
Mistake 2: Never Sending accessToken to the Backend at Login Time
The Problem
Pi.authenticate() returns { user, accessToken }. Many apps use the user object directly on the frontend to identify the user, without ever sending accessToken to the server for verification.
This is a security flaw: the frontend user object can be spoofed. The only trusted source of user identity is the Pi Platform API’s /me endpoint, which validates the accessToken server-side.
Wrong
const { user, accessToken } = await window.Pi.authenticate(['username', 'payments'], () => {});
// Storing user data from the client — this can be spoofed
localStorage.setItem('piUser', JSON.stringify(user));
// Never sending accessToken to the backend for verification
Correct
const { user, accessToken } = await window.Pi.authenticate(['username', 'payments'], onIncompletePayment);
// Always verify the accessToken with your backend immediately after auth
const res = await fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken }),
});
// The backend calls GET https://api.minepi.com/v2/me with Bearer <accessToken>
// and returns the trusted user data
const verifiedUser = await res.json();
Backend (any server):
GET https://api.minepi.com/v2/me
Authorization: Bearer <accessToken>
The response contains { uid, username, ... } — use only this uid to identify the user.
Mistake 3: Granting Features Before Server-Side Payment Completion
The Problem
The Pi payment flow has three phases:
- Server Approval (
onReadyForServerApproval) — your server tells Pi the payment is valid - Blockchain — Pi processes the transaction on-chain
- Server Completion (
onReadyForServerCompletion) — your server confirms completion and delivers the feature
A common mistake is granting the feature (e.g., premium mode, extra lives) at Phase 1 or when the payment modal closes, rather than waiting for Phase 3 to complete successfully on the server.
Wrong
// BAD: Granting the feature immediately when the user clicks "Buy"
// or when the payment modal opens
const buyPremium = () => {
Pi.createPayment({ amount: 1, memo: 'Premium', metadata: {} }, {
onReadyForServerApproval: (paymentId) => {
fetch('/approve', { method: 'POST', body: JSON.stringify({ paymentId }) });
},
onReadyForServerCompletion: (paymentId, txid) => {
fetch('/complete', { method: 'POST', body: JSON.stringify({ paymentId, txid }) });
},
onCancel: () => {},
onError: () => {},
});
// ERROR: Feature granted here, before payment is confirmed
setIsPremium(true);
};
Correct
window.Pi.createPayment(
{ amount: 1, memo: 'Unlock Premium', metadata: { feature: 'premium' } },
{
onReadyForServerApproval: async (paymentId) => {
// Phase I: Tell your server to approve with Pi Platform API
await fetch('/api/payment/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId }),
});
},
onReadyForServerCompletion: async (paymentId, txid) => {
// Phase III: Server calls Pi Platform API to complete, then delivers feature
const res = await fetch('/api/payment/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId, txid }),
});
if (res.ok) {
// Only unlock feature AFTER backend confirms completion
setIsPremium(true);
}
},
onCancel: (paymentId) => {
console.log('Payment cancelled:', paymentId);
// No feature granted
},
onError: (error) => {
console.error('Payment error:', error);
// No feature granted
},
}
);
Backend – complete endpoint must call Pi Platform API:
POST https://api.minepi.com/v2/payments/{paymentId}/complete
Authorization: Key <YOUR_PI_API_KEY>
Content-Type: application/json
{ "txid": "<txid>" }
Only after a 200 response from Pi should you deliver the purchased feature to the user.
Mistake 4: Payment Callback Endpoint Path Mismatch
The Problem
Pi.createPayment() callbacks must POST paymentId / txid to your backend routes. If the frontend calls a path your server does not expose, the request returns 404 and the payment stalls in approval or completion.
Wrong
// Backend exposes: POST /api/payments/approve
// Frontend calls: POST /pi_payment/approve ← 404!
window.Pi.createPayment(
{ amount: 1, memo: 'Premium', metadata: {} },
{
onReadyForServerApproval: (paymentId) =>
fetch('/pi_payment/approve', { method: 'POST', body: JSON.stringify({ paymentId }) }),
onReadyForServerCompletion: (paymentId, txid) =>
fetch('/pi_payment/complete', { method: 'POST', body: JSON.stringify({ paymentId, txid }) }),
onCancel: () => {},
onError: (err) => console.error(err),
}
);
Correct
Point the callbacks at the routes your backend actually implements:
window.Pi.createPayment(
{ amount: 1, memo: 'Premium', metadata: { feature: 'premium' } },
{
onReadyForServerApproval: (paymentId) =>
fetch('/api/payments/approve', { method: 'POST', body: JSON.stringify({ paymentId }) }),
onReadyForServerCompletion: (paymentId, txid) =>
fetch('/api/payments/complete', { method: 'POST', body: JSON.stringify({ paymentId, txid }) }),
onCancel: (paymentId) =>
fetch('/api/payments/cancel', { method: 'POST', body: JSON.stringify({ paymentId }) }),
onError: (err) => console.error(err),
}
);
Mistake 5: Not Sending Auth Headers for Protected Backend Endpoints
The Problem
Backend endpoints that perform user-specific actions (granting rewards, updating scores, delivering purchased features) need to know which user is making the request. If your backend uses token-based auth (resolving current_user via Authorization: Bearer <accessToken>), forgetting to include the header means current_user is nil and the user-specific operation silently does nothing.
Wrong
// Rewarded ad verified, but no auth header — current_user will be nil on the backend
const verifyRes = await fetch('/api/ads/reward', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adId }),
// Missing: Authorization header
});
Correct
const verifyRes = await fetch('/api/ads/reward', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`, // Always include auth
},
body: JSON.stringify({ adId }),
});
Store the accessToken from Pi.authenticate() in component state and pass it to all authenticated API calls.
Mistake 6: Not Verifying Rewarded Ad Server-Side
The Problem
After Pi.Ads.showAd('rewarded') resolves with { result: 'AD_REWARDED', adId }, some apps grant the reward immediately based on the client-side result. The Pi SDK client response can be spoofed. The adId must always be verified against the Pi Platform API before granting any reward.
Wrong
const result = await window.Pi.Ads.showAd('rewarded');
if (result.result === 'AD_REWARDED') {
addExtraLife(); // BAD: Trusting client result only
}
Correct
const result = await window.Pi.Ads.showAd('rewarded');
if (result.result === 'AD_REWARDED' && result.adId) {
// Always verify server-side
const res = await fetch('/api/ads/reward', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify({ adId: result.adId }),
});
const data = await res.json();
if (data.rewarded) {
addExtraLife(); // Only reward after server confirms
}
}
Backend – must check mediator_ack_status:
GET https://api.minepi.com/v2/ads_network/status/{adId}
Authorization: Key <YOUR_PI_API_KEY>
Only grant the reward if mediator_ack_status === "granted".
Mistake 7: Accessing accessToken via Non-Standard Internal Properties
The Problem
Pi.authenticate() returns { user, accessToken }. Some implementations try to read the token from undocumented properties such as window.PiSdkBase?.accessToken. Those properties are not part of the public Pi SDK API, are not guaranteed to exist, and typically return undefined or an empty string.
The result is that server-side token verification silently fails. Every subsequent authenticated backend call then fails with a 401, or the backend receives a request it cannot associate with a real user.
Wrong
const auth = await window.Pi.authenticate(['username', 'payments'], onIncompletePayment);
const accessToken = window.PiSdkBase?.accessToken ?? ''; // not a public API — usually empty
Correct
Use the accessToken returned by Pi.authenticate(), then send it to your backend immediately.
const { user, accessToken } = await window.Pi.authenticate(
['username', 'payments'],
onIncompletePayment
);
fetch('/api/auth/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accessToken }),
})
.then(r => r.json())
.then(data => {
if (data.uid) onVerified({ uid: data.uid, username: data.username }, accessToken);
});
Mistake 8: Calling the Completion Endpoint with Empty paymentId / txid
The Problem
onReadyForServerCompletion is the only client callback that provides the real paymentId and txid. Completing a payment with empty or fabricated values causes the Platform API to reject the request (POST /v2/payments//complete returns 400 or 404). The client may look finished while the server never confirmed the payment with Pi.
Wrong
window.Pi.createPayment(
{ amount: 0.1, memo: 'Extra Lives x3', metadata: {} },
{
onReadyForServerApproval: (paymentId) => {
fetch('/api/payment/approve', {
method: 'POST',
body: JSON.stringify({ paymentId }),
});
},
onReadyForServerCompletion: () => {
fetch('/api/payment/complete', {
method: 'POST',
body: JSON.stringify({ paymentId: '', txid: '' }), // broken
});
onExtraLivesGranted(3); // granted before any real verification
},
onCancel: () => {},
onError: () => {},
}
);
Correct
Use the paymentId and txid arguments from the callback, and deliver the feature only after the backend confirms completion.
window.Pi.createPayment(
{ amount: 0.1, memo: 'Extra Lives x3', metadata: { purpose: 'extra_lives', quantity: 3 } },
{
onReadyForServerApproval: async (paymentId) => {
await fetch('/api/payment/approve', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
body: JSON.stringify({ paymentId }),
});
},
onReadyForServerCompletion: async (paymentId, txid) => {
const res = await fetch('/api/payment/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
body: JSON.stringify({ paymentId, txid }),
});
if (res.ok) {
onExtraLivesGranted(3);
}
},
onCancel: (paymentId) => console.log('Payment cancelled:', paymentId),
onError: (err) => console.error('Payment error:', err),
}
);
Quick Reference: Correct Integration Checklist
-
<script src="https://sdk.minepi.com/pi-sdk.js"></script>inindex.html— required; the Pi Browser does not injectwindow.Pifor you -
Pi.init({ version: "2.0", sandbox: true })called before any Pi SDK methods (sandbox: falsein production) -
Pi.authenticate()called with at least['username', 'payments']scopes when the app accepts payments -
accessTokentaken from thePi.authenticate()return value — do not read it from undocumented properties -
accessTokensent to backend for verification via/meimmediately after authentication - Backend verifies
accessTokenusingGET /v2/mewithBearer <accessToken>header - Backend uses
Key <PI_API_KEY>for all Pi Platform API server-to-server calls -
onReadyForServerApprovalcalls backend → backend calls Pi/payments/{id}/approve -
onReadyForServerCompletioncalls backend with the realpaymentIdandtxid→ backend calls Pi/payments/{id}/complete - Feature delivered only after server-side completion confirms success
- Rewarded ad:
isAdReady→requestAd→showAd→ server-sideadIdverification - Reward granted only after
mediator_ack_status === "granted"from Pi API -
Authorization: Bearer <accessToken>header included in all authenticated backend calls