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

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.

Wrong

// 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

Correct

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.


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

When 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

// 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

Correct

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-react is installed, never call window.Pi.authenticate(), window.Pi.createPayment(), or window.Pi.init() directly. The hooks own those calls. The only acceptable window.Pi.* access alongside pi-sdk-react is window.Pi.Ads.* (since no ads hook is provided). Calling both the hook and window.Pi.authenticate() directly produces duplicate auth flows and race conditions.


Mistake 8: Calling the Completion Endpoint Manually with Empty paymentId / txid After usePiPurchase

The Problem

When 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:

  • The Pi Platform API receives a completion request with an empty payment ID (e.g., POST /v2/payments//complete), which returns a 400 or 404.
  • Feature delivery is based on a failed or unverified API call.
  • The payment may appear completed on the client while the server has not actually confirmed completion with Pi.

Wrong

// 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

Correct — Option A: Poll a user-state endpoint after usePiPurchase resolves

await 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.

Correct — Option B: Use window.Pi.createPayment() directly with real callback values

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) => {
      // 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),
  }
);

Mistake 9: Using Raw HTTP Calls Instead of the Official Backend SDK Package

The Problem

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.

Wrong

# 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()

Correct

# 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.


Quick Reference: Correct Integration Checklist

  • Backend uses the official framework SDK package: 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 methods
  • If using pi-sdk-react: use usePiConnection() for auth — never call window.Pi.authenticate() directly. Get accessToken from const { connected, accessToken } = usePiConnection().
  • If NOT using pi-sdk-react: call Pi.authenticate() with at least ['username', 'payments'] scopes
  • accessToken sent to backend for verification via /me immediately after connection
  • Do not read accessToken from non-standard properties like window.PiSdkBase?.accessToken
  • Backend verifies accessToken using GET /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 → backend calls Pi /payments/{id}/complete
  • When using usePiPurchase(), do not manually call the completion endpoint with empty paymentId/txid after the hook resolves — poll a user-state endpoint instead
  • 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