Skip to main content

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:

  1. Server Approval (onReadyForServerApproval) — your server tells Pi the payment is valid
  2. Blockchain — Pi processes the transaction on-chain
  3. 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> in index.html — required; the Pi Browser does not inject window.Pi for you
  • Pi.init({ version: "2.0", sandbox: true }) called before any Pi SDK methods (sandbox: false in production)
  • Pi.authenticate() called with at least ['username', 'payments'] scopes when the app accepts payments
  • accessToken taken from the Pi.authenticate() return value — do not read it from undocumented properties
  • accessToken sent to backend for verification via /me immediately after authentication
  • Backend verifies accessToken using GET /v2/me with Bearer <accessToken> header
  • Backend uses Key <PI_API_KEY> for all Pi Platform API server-to-server calls
  • onReadyForServerApproval calls backend → backend calls Pi /payments/{id}/approve
  • onReadyForServerCompletion calls backend with the real paymentId and txid → backend calls Pi /payments/{id}/complete
  • Feature delivered only after server-side completion confirms success
  • Rewarded ad: isAdReadyrequestAdshowAd → server-side adId verification
  • Reward granted only after mediator_ack_status === "granted" from Pi API
  • Authorization: Bearer <accessToken> header included in all authenticated backend calls