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.
pi-sdk.js Script Tag and Pi.init() CallEvery 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.
<!-- 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
<!-- 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
accessToken to the Backend at Login TimePi.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.
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
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.
The Pi payment flow has three phases:
onReadyForServerApproval) — your server tells Pi the payment is validonReadyForServerCompletion) — your server confirms completion and delivers the featureA 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.
// 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);
};
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.
When using a library like pi-sdk-react’s usePiPurchase, the hook has default endpoint paths for approval and completion (typically /pi_payment/approve and /pi_payment/complete). If your backend uses different paths (e.g., /api/payments/approve/), the callbacks will call the wrong URLs and receive 404 errors, causing payments to stall.
// Django backend exposes: POST /api/payments/approve/
// But usePiPurchase calls: POST /pi_payment/approve ← 404!
const buyPremium = usePiPurchase({ amount: 1, memo: 'Premium', metadata: {} });
// No custom endpoint configuration — path mismatch with Django backend
Option A: Use Pi.createPayment() directly with explicit URLs pointing to your backend:
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),
}
);
Option B: Configure your backend routes to match the library’s default paths (e.g., mount routes at /pi_payment/...).
Option C: Check if usePiPurchase accepts custom endpoint configuration and pass your backend’s paths explicitly.
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.
// 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
});
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.
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.
const result = await window.Pi.Ads.showAd('rewarded');
if (result.result === 'AD_REWARDED') {
addExtraLife(); // BAD: Trusting client result only
}
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".
accessToken via Non-Standard Internal PropertiesWhen using usePiConnection() from pi-sdk-react, some developers try to access the accessToken through non-standard properties like window.PiSdkBase?.accessToken because the hook’s documented return type does not expose accessToken. These internal properties are not part of the public Pi SDK API, are not guaranteed to exist, and will return undefined or an empty string in most Pi Browser environments.
The result is that the server-side token verification silently fails, leaving the app with an empty accessToken for the entire session. Every subsequent authenticated backend call then fails with a 401, or worse, the backend silently receives a request it cannot associate with a real user.
// WRONG: PiSdkBase is not a documented Pi SDK property
const { connected, user, ready } = usePiConnection();
const accessToken = (window as any).PiSdkBase?.accessToken ?? ''; // will be '' in production
usePiConnection() returns accessToken directly in its result. Destructure it and send it to your backend after connected === true. Do NOT call window.Pi.authenticate() again — that duplicates the auth call the hook already made internally.
import { useEffect } from 'react';
import { usePiConnection } from 'pi-sdk-react';
export function PiAuthPanel({ onVerified }) {
// accessToken is returned directly by the hook — no second authenticate() needed
const { connected, ready, accessToken } = usePiConnection();
useEffect(() => {
if (!connected || !ready || !accessToken) return;
// Immediately verify with the backend (Mistake 2)
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);
});
}, [connected, ready, accessToken, onVerified]);
}
Rule for LLM agents: When
pi-sdk-reactis installed, never callwindow.Pi.authenticate(),window.Pi.createPayment(), orwindow.Pi.init()directly. The hooks own those calls. The only acceptablewindow.Pi.*access alongsidepi-sdk-reactiswindow.Pi.Ads.*(since no ads hook is provided). Calling both the hook andwindow.Pi.authenticate()directly produces duplicate auth flows and race conditions.
paymentId / txid After usePiPurchaseWhen using usePiPurchase() from pi-sdk-react, the hook internally handles all three payment phases, including posting to /pi_payment/approve and /pi_payment/complete with the real paymentId and txid values. A common mistake is to then make a second, manual call to a custom completion endpoint after the hook resolves — but at that point you no longer have access to the paymentId or txid, leading to calls with empty or fabricated values.
Consequences:
POST /v2/payments//complete), which returns a 400 or 404.// usePiPurchase has already handled approve + complete internally
await buyExtraLives();
// BAD: paymentId and txid are empty — the hook doesn't expose them
await fetch('/api/payment/complete/', {
method: 'POST',
headers: { 'Authorization': `Bearer ${accessToken}` },
body: JSON.stringify({ paymentId: '', txid: '' }), // broken
});
onExtraLivesGranted(3); // granted before any real verification
usePiPurchase resolvesawait buyExtraLives(); // hook handles approve + complete
// Poll backend for updated feature state — do NOT grant optimistically
const res = await fetch('/api/user-state/', {
headers: { 'Authorization': `Bearer ${accessToken}` },
});
if (res.ok) {
const state = await res.json();
// Grant only what the backend confirms was delivered
onExtraLivesGranted(state.extraLives);
}
The backend should deliver the feature inside its payment completion handler (whatever callback or signal the payment framework provides) and expose the resulting state via a separate /api/user-state/ endpoint.
window.Pi.createPayment() directly with real callback valueswindow.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) => {
// paymentId and txid come from the Pi SDK — they are real values
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),
}
);
Each backend framework has an official Pi SDK package that manages the payment lifecycle, handles error recovery, and ensures correct API call sequences:
| Framework | Required package | Auth utility provided? |
|---|---|---|
| Express | pi-sdk-express (npm) |
No — implement /v2/me call manually |
| Django | pi-sdk-django (pip) |
Yes — use pi_sdk_django.client.verify_user(token) |
| Rails | pi-sdk-rails (gem) |
No — implement /v2/me call manually via Faraday |
A common mistake is to skip the backend package entirely and call the Pi Platform API
directly using requests (Python), axios (Node), or Faraday (Ruby) for the payment
lifecycle (approve, complete, cancel, incomplete). This bypasses the official SDK and
makes the integration non-compliant with Pi’s supported integration path.
Exception — auth token verification (GET /v2/me): pi-sdk-express and pi-sdk-rails
do not provide a token verification utility. Raw HTTP calls to GET /v2/me with
Authorization: Bearer <accessToken> are the correct pattern for those frameworks.
For Django, always use pi_sdk_django.client.verify_user(token) instead of a raw
requests call — the client is available and should be used.
# requirements.txt — WRONG: pi-sdk-django is missing
Django>=4.2,<5.0
django-cors-headers>=4.3
requests>=2.31 # raw HTTP client, not the Pi SDK
python-decouple>=3.8
# pi_client.py — WRONG: manual API calls instead of pi-sdk-django
import requests
def approve_payment(payment_id):
return requests.post(
f"https://api.minepi.com/v2/payments/{payment_id}/approve",
headers={"Authorization": f"Key {PI_API_KEY}"}
).json()
# requirements.txt — CORRECT: include pi-sdk-django
pi-sdk-django>=<version>
Django>=4.2,<5.0
django-cors-headers>=4.3
python-decouple>=3.8
Then use the package’s managed payment views instead of writing raw API calls.
The same principle applies to Express and Rails — always install pi-sdk-express or
pi-sdk-rails rather than reimplementing the lifecycle with a raw HTTP client.
pi-sdk-express (Express), pi-sdk-django (Django), or pi-sdk-rails (Rails) — do not substitute raw HTTP calls for payment lifecycle (approve/complete/cancel/incomplete); raw GET /v2/me is acceptable for auth verification in Express and Rails since those SDKs provide no auth utility; in Django, use pi_sdk_django.client.verify_user(token) instead<script src="https://sdk.minepi.com/pi-sdk.js"></script> in index.html (only needed for sandbox/desktop testing; Pi Browser injects window.Pi natively)Pi.init({ version: "2.0", sandbox: true }) called before any Pi SDK methodspi-sdk-react: use usePiConnection() for auth — never call window.Pi.authenticate() directly. Get accessToken from const { connected, accessToken } = usePiConnection().pi-sdk-react: call Pi.authenticate() with at least ['username', 'payments'] scopesaccessToken sent to backend for verification via /me immediately after connectionaccessToken from non-standard properties like window.PiSdkBase?.accessTokenaccessToken using GET /me with Bearer <accessToken> headerKey <PI_API_KEY> for all Pi Platform API server-to-server callsonReadyForServerApproval calls backend → backend calls Pi /payments/{id}/approveonReadyForServerCompletion calls backend → backend calls Pi /payments/{id}/completeusePiPurchase(), do not manually call the completion endpoint with empty paymentId/txid after the hook resolves — poll a user-state endpoint insteadisAdReady → requestAd → showAd → server-side adId verificationmediator_ack_status === "granted" from Pi APIAuthorization: Bearer <accessToken> header included in all authenticated backend calls