Status: Setup required before production deployment
Date: December 31, 2025
Version: 1.0
- Overview
- Google Play Billing Setup
- Apple App Store Setup
- Environment Variables Configuration
- Sandbox Integration Testing
- Webhook Configuration
- Production Deployment
- Monitoring & Verification
- Troubleshooting
This guide covers:
- Setting up real API credentials for Google Play & Apple App Store
- Testing with sandbox/staging credentials (NOT production)
- Integrating webhooks for real notifications
- Verification before production rollout
Timeline: 2-3 hours for complete setup
Risk Level: LOW (using sandbox, won't charge real money)
# Visit Google Cloud Console
https://console.cloud.google.com/
# Create new project or use existing
Project Name: "LongEage IAP"Services to enable:
1. Google Play Android Developer API
2. Google Cloud Pub/Sub API
3. Service Usage API
How to enable:
1. Go to APIs & Services > Library
2. Search for "Google Play Android Developer API"
3. Click "Enable"
4. Repeat for Cloud Pub/Sub API
# In Google Cloud Console
APIs & Services > Credentials > Create Credentials > Service Account
Service Account Name: "longeage-iap-service"
Grant roles:
- Editor (for testing)
- Pub/Sub Editor (for webhooks)# After creating service account
1. Click on the service account
2. Go to "Keys" tab
3. Click "Add Key" > "Create new key"
4. Choose JSON format
5. Download file: `service-account-key.json`
File content should look like:
{
"type": "service_account",
"project_id": "longeage-iap-...",
"private_key_id": "...",
"private_key": "-----BEGIN PRIVATE KEY-----\n...",
"client_email": "longeage-iap-service@...",
"client_id": "...",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "..."
}Link your Android app to the service account:
Google Play Console > App Settings > Users and Permissions
1. Invite user with email: longeage-iap-service@[PROJECT_ID].iam.gserviceaccount.com
2. Grant role: "Admin"
3. Accept invitation (via service account email)
Note: Service account won't accept invitations via email.
Use OAuth flow or create key in Google Cloud, not Play Console.
# In Google Cloud Console
Pub/Sub > Topics > Create Topic
Topic Name: "longeage-iap-notifications"
# Then create subscription
Subscriptions > Create Subscription
Subscription Name: "longeage-iap-webhook"
Topic: "longeage-iap-notifications"
Delivery Type: "Push"
Push Endpoint: "https://your-backend.com/webhooks/iap/google"
(Replace with your actual backend URL)Developer Account > Apps > Your App > App ID (Bundle ID)
Example: com.longeage.app
App Store Connect > Your App > In-App Purchases
1. Scroll to "App-Specific Shared Secret"
2. If not created, click "Generate"
3. Copy the secret (long alphanumeric string)
Save as: APPLE_SHARED_SECRET in .env
Developer Account > Apps > Your App > App Information
Scroll to "App Store Server Notifications"
1. Event notification endpoint: https://your-backend.com/webhooks/iap/apple
2. Check: "Server Notifications Version 2" (important!)
3. Save
If you want to verify receipts server-to-server (not needed for webhooks):
Developer Account > Keys
1. Create new key: "Longeage IAP Verification"
2. Select "In-App Purchase" permissions
3. Download key file (named: AuthKey_[KEY_ID].p8)
4. Save somewhere safe
Key details to note:
- Key ID: [KEY_ID] from filename
- Team ID: Found in Account > Membership > Team ID
- Bundle ID: Your app's bundle ID
# Google Play
GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json"
GOOGLE_PLAY_PACKAGE_NAME="com.longeage.app"
GOOGLE_PUBSUB_TOPIC="projects/[PROJECT_ID]/topics/longeage-iap-notifications"
GOOGLE_PUBSUB_SUBSCRIPTION="longeage-iap-webhook"
# Apple
APPLE_SHARED_SECRET="[Your Shared Secret from App Store Connect]"
APPLE_BUNDLE_ID="com.longeage.app"
APPLE_TEAM_ID="[Your Team ID]"
APPLE_KEY_ID="[Your Key ID if using server verification]"
# IAP Mode (IMPORTANT!)
IAP_MODE="REAL" # Change from MOCK when ready
# Webhook URLs
WEBHOOK_GOOGLE_URL="https://your-backend.com/webhooks/iap/google"
WEBHOOK_APPLE_URL="https://your-backend.com/webhooks/iap/apple"# Check if variables are loaded
npm run dev
# In your backend logs, you should see:
# β
IAP Mode: REAL
# β
Google Play Package: com.longeage.app
# β
Apple Bundle ID: com.longeage.appTest Script: Create scripts/test-iap-google.ts
import { google } from "googleapis";
async function testGooglePlayAPI() {
try {
const auth = new google.auth.GoogleAuth({
keyFile: process.env.GOOGLE_APPLICATION_CREDENTIALS,
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const androidpublisher = google.androidpublisher({
version: "v3",
auth,
});
// Test with a sandbox product
const response = await androidpublisher.purchases.subscriptions.get({
packageName: process.env.GOOGLE_PLAY_PACKAGE_NAME!,
subscriptionId: "longeage_premium_1m_sandbox", // Use sandbox product
token: "SANDBOX_TEST_TOKEN", // Special token for testing
});
console.log("β
Google Play API connection successful");
console.log("Response:", response.data);
} catch (error) {
console.error("β Google Play API error:", error);
}
}
testGooglePlayAPI();Run:
npx ts-node scripts/test-iap-google.tsExpected Result:
β
Google Play API connection successful
Response: { ... }
Test Script: Create scripts/test-iap-apple.ts
import https from "https";
async function testAppleAPI() {
const receiptData = Buffer.from("SANDBOX_RECEIPT_DATA_HERE").toString(
"base64",
);
const payload = JSON.stringify({
"receipt-data": receiptData,
password: process.env.APPLE_SHARED_SECRET,
"exclude-old-transactions": true,
});
return new Promise<void>((resolve, reject) => {
const options = {
hostname: "sandbox.itunes.apple.com", // Use sandbox!
path: "/verifyReceipt",
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": payload.length,
},
};
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
const response = JSON.parse(data);
if (response.status === 0) {
console.log("β
Apple API connection successful");
console.log("Response:", response);
resolve();
} else {
console.error(
`β Apple API error: Status ${response.status}`,
response,
);
reject(new Error(`Status: ${response.status}`));
}
});
});
req.on("error", reject);
req.write(payload);
req.end();
});
}
testAppleAPI().catch(console.error);Run:
# First, get a sandbox receipt from your test app
# Then replace SANDBOX_RECEIPT_DATA_HERE with actual receipt
npx ts-node scripts/test-iap-apple.tsExpected Result:
β
Apple API connection successful
Response: { status: 0, ... }
# 1. Create test user in database
# (Use your actual user ID from testing app)
# 2. Get JWT token for test user
# (See /auth/login endpoint)
# 3. Test POST /iap/verify with real token from sandbox app
curl -X POST http://localhost:3000/iap/verify \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"platform": "ANDROID",
"packageName": "com.longeage.app",
"productId": "longeage_premium_1m",
"token": "SANDBOX_PURCHASE_TOKEN_FROM_APP"
}'
# Expected response:
{
"success": true,
"data": {
"success": true,
"status": "ACTIVE",
"expiryDate": "2025-01-30T12:34:56.789Z",
"isPremiumNow": true,
"daysRemaining": 30
}
}# Manually trigger cron job (for testing)
# Option 1: Create admin endpoint
POST /admin/iap/trigger-cleanup
Authorization: Bearer ADMIN_TOKEN
# Response:
{
"success": true,
"marked": 0, // No expired subscriptions in sandbox
"message": "Marked 0 subscriptions as EXPIRED"
}
# Option 2: Wait for scheduled cron at 00:00 UTC
# Check logs for:
# "π΅οΈ [CRON] Daily subscription expiration cleanup..."1. Create Pub/Sub subscription (already done in setup)
2. Configure endpoint
# Your backend must accept POST requests at:
# POST /webhooks/iap/google
# Header validation:
# Google sends Authorization header (optional, validate if needed)
# Body format:
{
"message": {
"data": "base64_encoded_json",
"messageId": "...",
"publishTime": "..."
}
}
# Decoded data:
{
"packageName": "com.longeage.app",
"subscriptionId": "longeage_premium_1m",
"subscriptionNotification": {
"version": "1",
"notificationType": 2, // 2 = RENEWAL
"purchaseToken": "token_xyz"
}
}3. Test webhook locally
# Start your backend in REAL mode
npm run dev
# Send test notification via curl
curl -X POST http://localhost:3000/webhooks/iap/google \
-H "Content-Type: application/json" \
-d '{
"message": {
"data": "eyJwYWNrYWdlTmFtZSI6ImNvbS5sb25nZWFnZS5hcHAiLCJzdWJzY3JpcHRpb25JZCI6ImxvbmdlYWdlX3ByZW1pdW1fMW0iLCJzdWJzY3JpcHRpb25Ob3RpZmljYXRpb24iOnsidmVyc2lvbiI6IjEiLCJub3RpZmljYXRpb25UeXBlIjoyLCJwdXJjaGFzZVRva2VuIjoiLi4uIn19"
}
}'
# Check logs for:
# "β
Google Pub/Sub webhook processed"1. Register endpoint in App Store Connect
Already done in Apple setup section:
App Store Connect > Your App > App Information > App Store Server Notifications
Endpoint: https://your-backend.com/webhooks/iap/apple
Version: 2 (required!)
2. Verify webhook signature (important for production)
// In iap.webhook.controller.ts
// Validate Apple signature using:
// https://developer.apple.com/documentation/appstoreserverapi/app_store_server_notifications
import { verifyJWT } from 'apple-iap-verifier'; // or similar
async handleAppleWebhook(body: { signedPayload: string }) {
// 1. Verify JWT signature using Apple's public key
// 2. Decode payload
// 3. Process notification
}3. Test webhook locally
# For testing, you can skip signature verification
# (add flag: IAP_SKIP_APPLE_VERIFICATION=true in .env for tests)
# Send test notification
curl -X POST http://localhost:3000/webhooks/iap/apple \
-H "Content-Type: application/json" \
-d '{
"signedPayload": "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlCOHpDQ0EzeWdBd0lCQWdJUWZtOGlaWnp1cTBFSkFBQUFBSDd1UlRBTkJna3Foa2lHOXcwQkFRc0ZBREEzTVJzd0dRWURWUVFERXhwcmRXSmxjbTVsY3kxdGJ5MU5SVEF3Wm5SNVNtRkFNQjRYRFRJeFl6RXlOekEwTkRReE1Wb1hEVEkxWXpFNU5qYzBNVGRHTVRFd01UQkJCZ05WQkFFVEMwVlRVeEZXRVZUSWdjQkVnSktiV0ZzZFd5RWN3... (very long JWT)"
}'
# Check logs for:
# "β
Apple webhook processed: DID_RENEW"# 1. Verify all credentials are in place
echo $APPLE_SHARED_SECRET
echo $GOOGLE_APPLICATION_CREDENTIALS
# Both should return values (not empty)
# 2. Verify IAP_MODE is set to REAL
echo $IAP_MODE # Should print: REAL
# 3. Run integration tests
npm run test -- src/client/iap/iap.spec.ts
# All 4 tests should pass
# 4. Build production bundle
npm run build
# Should complete with 0 errors
# 5. Check for any hardcoded sandbox URLs
grep -r "sandbox.itunes" src/
# Should return NO results (use dynamic endpoint based on env)
# 6. Verify webhook URLs are production domains
grep -r "localhost" src/client/iap/
# Should return NO results# 1. Merge to production branch
git checkout production
git pull origin main
git push origin production
# 2. Deploy to production environment
# (Use your CI/CD pipeline)
# Docker example:
docker build -t longeage-app:iap-v1 .
docker push [YOUR_REGISTRY]/longeage-app:iap-v1
# Kubernetes example:
kubectl set image deployment/longeage-app \
longeage=longeage-app:iap-v1 \
-n production
# 3. Verify deployment
curl https://api.longeage.com/health
# Should return 200 OK
# 4. Check logs
kubectl logs -f deployment/longeage-app -n production | grep IAP
# Should show: "IAP Mode: REAL"# 1. Monitor webhook delivery
# Google Cloud Pub/Sub > Subscriptions > longeage-iap-webhook
# Check "Ack Message Count" increasing
# 2. Check Sentry for errors
# Sentry Dashboard > Issues
# Filter by "iap" tags
# Should be 0 errors
# 3. Verify premium users are working
# In app, test: GET /auth/me
# User with active subscription should have: is_premium: true
# 4. Monitor cron job execution
# Check application logs at 00:00 UTC daily
# Should see: "π΅οΈ [CRON] Daily subscription expiration cleanup..."1. Verification Success Rate
ββ /iap/verify endpoint
ββ Target: > 99%
ββ Alert if < 95% for 5 minutes
ββ Sentry: Monitor HTTP 400/500 responses
2. Webhook Processing Time
ββ /webhooks/iap/google and /webhooks/iap/apple
ββ Target: < 500ms average
ββ Alert if > 2s
ββ Metric: webhook_processing_time_ms
3. Premium Users Count
ββ Query: SELECT COUNT(*) FROM user WHERE is_premium = true
ββ Track daily trend
ββ Alert if drops > 10% without reason
4. Subscription Status Distribution
ββ ACTIVE count
ββ EXPIRED count
ββ CANCELLED count
ββ Useful for churn analysis
5. Cron Job Execution
ββ Scheduled daily at 00:00 UTC
ββ Duration: Should be < 5 seconds
ββ Alert if > 30 seconds
Create dashboard with:
- IAP verification success rate (last 24h)
- Webhook processing time (p50, p95, p99)
- Premium user count (trend line)
- Cron job duration (last 7 days)
- Error rate by endpoint
- Database query performance
# Search for IAP logs
Sentry/ELK/CloudWatch: tags.module=iap
# Example queries:
- ERROR in module: iap
ββ Shows all errors related to IAP
- metric: webhook_processing_time_ms > 2000
ββ Shows slow webhook processing
- event: cron_failed
ββ Shows failed cron executionsSymptoms:
- POST /iap/verify fails with 401
- Logs: "Invalid `googleAuth` signature"
Causes:
1. Service account credentials expired
2. Service account not added to Google Play Console
3. API not enabled in Google Cloud
Solutions:
1. Download new key from Google Cloud Console
2. Add service account email to Google Play Console > Settings > Users
3. Enable "Google Play Android Developer API" in Google Cloud > APIs
Test:
# Verify service account can access Google Play
curl -X GET \
-H "Authorization: Bearer $(gcloud auth application-default print-access-token)" \
https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.longeage.app/subscriptionsSymptoms:
- POST /iap/verify with Apple receipt fails
- Logs: "Apple API returned status: 21007"
Cause:
- Using sandbox receipt in production endpoint
- OR using production receipt in sandbox endpoint
Solution:
- Automatic retry: Endpoint detects 21007 and retries sandbox
- This is expected behavior and will work
- No action needed
Symptoms:
- /webhooks/iap/google not receiving requests
- Google Console shows "Ack Message Count" = 0
Checks:
1. Verify subscription exists
ββ Google Cloud > Pub/Sub > Subscriptions > longeage-iap-webhook
2. Verify push endpoint is correct
ββ Should be: https://your-backend.com/webhooks/iap/google
ββ Check for typos, SSL certificate valid
3. Test message delivery
ββ Go to Subscriptions > Publish test message
ββ Should see POST request in backend logs
4. Check backend logs for errors
ββ kubernetes logs | grep "webhook" | grep "ERROR"
ββ Fix any parsing errors
5. Verify health check endpoint
ββ curl https://your-backend.com/health
ββ Should return 200 OK (Pub/Sub requires healthy endpoint)
Symptoms:
- /webhooks/iap/apple not receiving requests
- Sandbox app not triggering renewal
Checks:
1. Verify endpoint is registered
ββ App Store Connect > Your App > App Information
ββ Look for "App Store Server Notifications"
ββ Check endpoint URL
2. Verify SSL certificate is valid
ββ Apple requires valid, non-self-signed certificate
ββ curl -I https://your-backend.com/webhooks/iap/apple
ββ Should return 200 OK (or expected response)
3. Check backend logs
ββ Should see incoming POST requests during renewal
ββ If not, check iOS test app's renewal logic
4. Test from sandbox app
ββ Trigger subscription renewal manually
ββ Check backend logs within 10 seconds
Symptoms:
- POST /iap/verify sometimes fails with timeout
- Logs: "Transaction timeout exceeded"
Cause:
- Multiple concurrent requests for same user
- Database lock contention
Solution 1: Increase transaction timeout
ββ iap.service.ts: Change timeout from 10s to 15s
Solution 2: Add request queuing
ββ Use Bull queue to serialize /iap/verify per user
ββ Prevents concurrent upsert on same subscription
Solution 3: Database optimization
ββ Ensure index on (userId, originalTransactionId)
ββ Run ANALYZE on subscription table
Check indexes:
-- In your database
SELECT indexname FROM pg_indexes
WHERE tablename = 'Subscription';
-- Should have indexes on:
-- - userId (for lookup)
-- - originalTransactionId (for uniqueness)
-- - status (for cron query)
-- - expiryDate (for cron range query)Symptoms:
- User verifies purchase via /iap/verify
- Response shows isPremium: true
- But GET /auth/me still shows is_premium: false
Cause:
- User field not synced after subscription update
- Cache not invalidated
Solution:
1. Clear user cache (if using Redis)
ββ redis-cli DEL user:{userId}
2. Refresh user session
ββ Logout and login again
ββ iOS/Android app should re-fetch via /auth/me
3. Force DB check
ββ SELECT is_premium FROM "User" WHERE id = '{userId}'
ββ Should show true if subscription.status = ACTIVE
- Google Play credentials downloaded and stored in .env
- Apple Shared Secret created and stored in .env
- Google Cloud Pub/Sub subscription created
- Apple webhook endpoint registered in App Store Connect
- All 4 integration tests pass
- Build completes with 0 errors
- No hardcoded sandbox URLs in code
- Webhook URLs point to production domain
- IAP_MODE environment variable set to "REAL"
- Database migration applied in production
- Monitoring/alerting configured in Sentry/Datadog
- Logs aggregation setup for troubleshooting
- Team trained on monitoring & troubleshooting
- Rollback plan documented
- On-call engineer identified for first week
- Gradual rollout plan ready (blue-green or canary)
- Google Play Billing Documentation
- Apple App Store Server Notifications
- Prisma Atomic Transactions
- Google Cloud Pub/Sub
Document Version: 1.0
Last Updated: 2025-12-31
Next Review: 2025-01-15 (post-deployment)