indie-subscription-server/SKILL.md
Handle iOS subscriptions server-side. Covers App Store Server API, receipt validation, Server Notifications V2, subscription status management, and grace periods.
npx skillsauth add abanoub-ashraf/manus-skills-import indie-subscription-serverInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
4 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Handle iOS subscriptions server-side. Covers App Store Server API, receipt validation, Server Notifications V2, subscription status management, and grace periods.
verifyReceipt endpoint (deprecated but still works)Recommendation: Use App Store Server API for new apps. Support receipt validation for older iOS versions.
.p8 key filehttps://api.yourapp.com/webhooks/appstorehttps://api.yourapp.com/webhooks/appstore/sandbox# App Store Connect API
APPSTORE_KEY_ID="XXXXXXXXXX"
APPSTORE_ISSUER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
APPSTORE_KEY_PATH="./keys/SubscriptionKey_XXX.p8"
APPSTORE_BUNDLE_ID="com.yourcompany.app"
# For receipt validation (legacy)
APPSTORE_SHARED_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
// prisma/schema.prisma
model Subscription {
id String @id @default(cuid())
userId String @unique
// Transaction identifiers
originalTransactionId String @unique
transactionId String?
webOrderLineItemId String?
// Product info
productId String
// Status
status SubscriptionStatus
environment String // Sandbox, Production
// Dates
purchaseDate DateTime
expiresDate DateTime?
renewalDate DateTime?
gracePeriodExpiresAt DateTime?
// Billing
isInBillingRetry Boolean @default(false)
autoRenewStatus Boolean @default(true)
autoRenewProductId String?
// Cancellation
cancellationDate DateTime?
cancellationReason Int?
// Price
price Int? // In milliunits (e.g., 4990 = $4.99)
currency String?
// Timestamps
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([originalTransactionId])
@@index([status])
@@index([expiresDate])
}
enum SubscriptionStatus {
ACTIVE
EXPIRED
BILLING_RETRY
GRACE_PERIOD
REVOKED
BILLING_GRACE_PERIOD
}
model SubscriptionEvent {
id String @id @default(cuid())
subscriptionId String?
originalTransactionId String
notificationType String
subtype String?
environment String
signedPayload String @db.Text
processedAt DateTime?
error String?
createdAt DateTime @default(now())
@@index([originalTransactionId])
@@index([createdAt])
}
npm install jsonwebtoken jose node-fetch
// src/lib/appstore/auth.ts
import jwt from 'jsonwebtoken'
import fs from 'fs'
const privateKey = fs.readFileSync(process.env.APPSTORE_KEY_PATH!, 'utf8')
let cachedToken: { token: string; expires: number } | null = null
export function getAppStoreToken(): string {
const now = Math.floor(Date.now() / 1000)
// Reuse token if valid (tokens valid for 1 hour)
if (cachedToken && cachedToken.expires > now + 60) {
return cachedToken.token
}
const payload = {
iss: process.env.APPSTORE_ISSUER_ID,
iat: now,
exp: now + 3600, // 1 hour
aud: 'appstoreconnect-v1',
bid: process.env.APPSTORE_BUNDLE_ID,
}
const token = jwt.sign(payload, privateKey, {
algorithm: 'ES256',
header: {
alg: 'ES256',
kid: process.env.APPSTORE_KEY_ID,
typ: 'JWT',
},
})
cachedToken = {
token,
expires: now + 3600,
}
return token
}
// src/lib/appstore/client.ts
import { getAppStoreToken } from './auth'
const PRODUCTION_URL = 'https://api.storekit.itunes.apple.com'
const SANDBOX_URL = 'https://api.storekit-sandbox.itunes.apple.com'
function getBaseUrl(environment: 'Production' | 'Sandbox'): string {
return environment === 'Production' ? PRODUCTION_URL : SANDBOX_URL
}
async function apiRequest<T>(
path: string,
environment: 'Production' | 'Sandbox',
options: RequestInit = {}
): Promise<T> {
const token = getAppStoreToken()
const url = `${getBaseUrl(environment)}${path}`
const response = await fetch(url, {
...options,
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({}))
throw new AppStoreError(response.status, error)
}
return response.json()
}
export class AppStoreError extends Error {
constructor(
public statusCode: number,
public details: any
) {
super(`App Store API error: ${statusCode}`)
}
}
// Get subscription status
export async function getSubscriptionStatus(
originalTransactionId: string,
environment: 'Production' | 'Sandbox' = 'Production'
): Promise<SubscriptionStatusResponse> {
return apiRequest(
`/inApps/v1/subscriptions/${originalTransactionId}`,
environment
)
}
// Get transaction history
export async function getTransactionHistory(
originalTransactionId: string,
environment: 'Production' | 'Sandbox' = 'Production',
revision?: string
): Promise<TransactionHistoryResponse> {
const params = revision ? `?revision=${revision}` : ''
return apiRequest(
`/inApps/v1/history/${originalTransactionId}${params}`,
environment
)
}
// Get all subscription statuses for a user
export async function getAllSubscriptionStatuses(
originalTransactionId: string,
environment: 'Production' | 'Sandbox' = 'Production'
): Promise<StatusResponse> {
return apiRequest(
`/inApps/v1/subscriptions/${originalTransactionId}`,
environment
)
}
// Request test notification (sandbox only)
export async function requestTestNotification(): Promise<{ testNotificationToken: string }> {
return apiRequest(
'/inApps/v1/notifications/test',
'Sandbox',
{ method: 'POST' }
)
}
// Extend subscription renewal date
export async function extendSubscriptionRenewalDate(
originalTransactionId: string,
extendByDays: number,
extendReasonCode: number,
environment: 'Production' | 'Sandbox' = 'Production'
): Promise<{ effectiveDate: number; originalTransactionId: string }> {
return apiRequest(
`/inApps/v1/subscriptions/extend/${originalTransactionId}`,
environment,
{
method: 'PUT',
body: JSON.stringify({
extendByDays,
extendReasonCode,
requestIdentifier: `extend-${Date.now()}`,
}),
}
)
}
// src/lib/appstore/types.ts
export interface SubscriptionStatusResponse {
environment: 'Production' | 'Sandbox'
bundleId: string
appAppleId: number
data: SubscriptionGroupIdentifierItem[]
}
export interface SubscriptionGroupIdentifierItem {
subscriptionGroupIdentifier: string
lastTransactions: LastTransactionsItem[]
}
export interface LastTransactionsItem {
status: SubscriptionStatus
originalTransactionId: string
signedTransactionInfo: string // JWS
signedRenewalInfo: string // JWS
}
export type SubscriptionStatus =
| 1 // Active
| 2 // Expired
| 3 // Billing retry
| 4 // Grace period
| 5 // Revoked
export interface DecodedTransaction {
transactionId: string
originalTransactionId: string
bundleId: string
productId: string
purchaseDate: number
originalPurchaseDate: number
expiresDate?: number
quantity: number
type: 'Auto-Renewable Subscription' | 'Non-Renewing Subscription' | 'Consumable' | 'Non-Consumable'
appAccountToken?: string
inAppOwnershipType: 'PURCHASED' | 'FAMILY_SHARED'
signedDate: number
environment: 'Production' | 'Sandbox'
transactionReason?: 'PURCHASE' | 'RENEWAL'
storefront: string
storefrontId: string
price?: number
currency?: string
}
export interface DecodedRenewalInfo {
originalTransactionId: string
autoRenewProductId: string
productId: string
autoRenewStatus: 0 | 1
renewalPrice?: number
currency?: string
signedDate: number
environment: 'Production' | 'Sandbox'
expirationIntent?: 1 | 2 | 3 | 4
isInBillingRetryPeriod?: boolean
gracePeriodExpiresDate?: number
offerType?: 1 | 2 | 3
offerIdentifier?: string
priceIncreaseStatus?: 0 | 1
}
// src/lib/appstore/verify.ts
import * as jose from 'jose'
// Apple's root certificates
const APPLE_ROOT_CA_G3_URL = 'https://www.apple.com/certificateauthority/AppleRootCA-G3.cer'
let applePublicKeys: jose.KeyLike[] | null = null
async function getApplePublicKeys(): Promise<jose.KeyLike[]> {
if (applePublicKeys) return applePublicKeys
// In production, cache these and refresh periodically
const response = await fetch(APPLE_ROOT_CA_G3_URL)
const cert = await response.arrayBuffer()
// Parse certificate and extract public key
// This is simplified - in production, verify the full certificate chain
applePublicKeys = []
return applePublicKeys
}
export async function verifyAndDecodeJWS<T>(signedPayload: string): Promise<T> {
// Decode without verification first to get the header
const [headerB64] = signedPayload.split('.')
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString())
// Extract the certificate chain from x5c header
const x5c = header.x5c as string[]
if (!x5c || x5c.length === 0) {
throw new Error('Missing x5c header')
}
// Get the signing certificate (first in chain)
const certPem = `-----BEGIN CERTIFICATE-----\n${x5c[0]}\n-----END CERTIFICATE-----`
const publicKey = await jose.importX509(certPem, 'ES256')
// Verify the signature
const { payload } = await jose.jwtVerify(signedPayload, publicKey, {
algorithms: ['ES256'],
})
return payload as T
}
export async function decodeTransaction(
signedTransactionInfo: string
): Promise<DecodedTransaction> {
return verifyAndDecodeJWS<DecodedTransaction>(signedTransactionInfo)
}
export async function decodeRenewalInfo(
signedRenewalInfo: string
): Promise<DecodedRenewalInfo> {
return verifyAndDecodeJWS<DecodedRenewalInfo>(signedRenewalInfo)
}
// src/routes/webhooks/appstore.ts
import { Hono } from 'hono'
import { prisma } from '../../lib/db'
import { verifyAndDecodeJWS, decodeTransaction, decodeRenewalInfo } from '../../lib/appstore/verify'
import { processNotification } from '../../services/subscription'
export const appStoreWebhook = new Hono()
interface NotificationPayload {
notificationType: NotificationType
subtype?: NotificationSubtype
notificationUUID: string
data: {
appAppleId: number
bundleId: string
bundleVersion: string
environment: 'Production' | 'Sandbox'
signedTransactionInfo: string
signedRenewalInfo?: string
}
version: string
signedDate: number
}
type NotificationType =
| 'SUBSCRIBED'
| 'DID_RENEW'
| 'DID_CHANGE_RENEWAL_PREF'
| 'DID_CHANGE_RENEWAL_STATUS'
| 'DID_FAIL_TO_RENEW'
| 'GRACE_PERIOD_EXPIRED'
| 'EXPIRED'
| 'OFFER_REDEEMED'
| 'PRICE_INCREASE'
| 'REFUND'
| 'REFUND_DECLINED'
| 'REFUND_REVERSED'
| 'RENEWAL_EXTENDED'
| 'REVOKE'
| 'TEST'
type NotificationSubtype =
| 'INITIAL_BUY'
| 'RESUBSCRIBE'
| 'DOWNGRADE'
| 'UPGRADE'
| 'AUTO_RENEW_ENABLED'
| 'AUTO_RENEW_DISABLED'
| 'VOLUNTARY'
| 'BILLING_RETRY'
| 'PRICE_INCREASE'
| 'GRACE_PERIOD'
| 'BILLING_RECOVERY'
| 'PENDING'
| 'ACCEPTED'
appStoreWebhook.post('/', async (c) => {
try {
const body = await c.req.json()
const signedPayload = body.signedPayload as string
if (!signedPayload) {
return c.json({ error: 'Missing signedPayload' }, 400)
}
// Verify and decode the notification
const notification = await verifyAndDecodeJWS<NotificationPayload>(signedPayload)
// Decode transaction info
const transaction = await decodeTransaction(notification.data.signedTransactionInfo)
// Store the event
const event = await prisma.subscriptionEvent.create({
data: {
originalTransactionId: transaction.originalTransactionId,
notificationType: notification.notificationType,
subtype: notification.subtype,
environment: notification.data.environment,
signedPayload,
},
})
// Process the notification
await processNotification(notification, transaction)
// Mark as processed
await prisma.subscriptionEvent.update({
where: { id: event.id },
data: { processedAt: new Date() },
})
return c.json({ success: true })
} catch (error: any) {
console.error('Webhook error:', error)
// Store failed event for retry
try {
const body = await c.req.json()
await prisma.subscriptionEvent.create({
data: {
originalTransactionId: 'unknown',
notificationType: 'ERROR',
environment: 'Unknown',
signedPayload: body.signedPayload || '',
error: error.message,
},
})
} catch {}
return c.json({ error: 'Processing failed' }, 500)
}
})
// Sandbox webhook (separate endpoint for clarity)
appStoreWebhook.post('/sandbox', async (c) => {
// Same logic, but you might want to handle sandbox differently
return c.json({ success: true })
})
// src/services/subscription.ts
import { prisma } from '../lib/db'
import {
getSubscriptionStatus,
decodeTransaction,
decodeRenewalInfo
} from '../lib/appstore'
import { SubscriptionStatus } from '@prisma/client'
export async function processNotification(
notification: NotificationPayload,
transaction: DecodedTransaction
): Promise<void> {
const { notificationType, subtype, data } = notification
const originalTransactionId = transaction.originalTransactionId
// Find existing subscription
let subscription = await prisma.subscription.findUnique({
where: { originalTransactionId },
})
// Decode renewal info if available
let renewalInfo: DecodedRenewalInfo | undefined
if (data.signedRenewalInfo) {
renewalInfo = await decodeRenewalInfo(data.signedRenewalInfo)
}
switch (notificationType) {
case 'SUBSCRIBED':
if (subtype === 'INITIAL_BUY' || subtype === 'RESUBSCRIBE') {
await handleNewSubscription(transaction, renewalInfo)
}
break
case 'DID_RENEW':
await handleRenewal(transaction, renewalInfo)
break
case 'DID_FAIL_TO_RENEW':
await handleFailedRenewal(originalTransactionId, subtype, renewalInfo)
break
case 'GRACE_PERIOD_EXPIRED':
await handleGracePeriodExpired(originalTransactionId)
break
case 'EXPIRED':
await handleExpired(originalTransactionId, subtype)
break
case 'DID_CHANGE_RENEWAL_STATUS':
await handleRenewalStatusChange(originalTransactionId, subtype, renewalInfo)
break
case 'DID_CHANGE_RENEWAL_PREF':
await handleRenewalPrefChange(originalTransactionId, renewalInfo)
break
case 'REFUND':
await handleRefund(originalTransactionId, transaction)
break
case 'REVOKE':
await handleRevoke(originalTransactionId)
break
case 'RENEWAL_EXTENDED':
await handleRenewalExtended(originalTransactionId, transaction)
break
case 'TEST':
console.log('Received test notification')
break
default:
console.log(`Unhandled notification type: ${notificationType}`)
}
}
async function handleNewSubscription(
transaction: DecodedTransaction,
renewalInfo?: DecodedRenewalInfo
): Promise<void> {
const { originalTransactionId, productId, expiresDate } = transaction
// Find user by app account token or create association
// This depends on how you link iOS users to server accounts
const userId = await findOrCreateUserForTransaction(transaction)
await prisma.subscription.upsert({
where: { originalTransactionId },
create: {
userId,
originalTransactionId,
transactionId: transaction.transactionId,
productId,
status: SubscriptionStatus.ACTIVE,
environment: transaction.environment,
purchaseDate: new Date(transaction.purchaseDate),
expiresDate: expiresDate ? new Date(expiresDate) : null,
autoRenewStatus: renewalInfo?.autoRenewStatus === 1,
autoRenewProductId: renewalInfo?.autoRenewProductId,
price: transaction.price,
currency: transaction.currency,
},
update: {
transactionId: transaction.transactionId,
productId,
status: SubscriptionStatus.ACTIVE,
expiresDate: expiresDate ? new Date(expiresDate) : null,
autoRenewStatus: renewalInfo?.autoRenewStatus === 1,
autoRenewProductId: renewalInfo?.autoRenewProductId,
price: transaction.price,
currency: transaction.currency,
isInBillingRetry: false,
gracePeriodExpiresAt: null,
},
})
}
async function handleRenewal(
transaction: DecodedTransaction,
renewalInfo?: DecodedRenewalInfo
): Promise<void> {
const { originalTransactionId, expiresDate } = transaction
await prisma.subscription.update({
where: { originalTransactionId },
data: {
transactionId: transaction.transactionId,
status: SubscriptionStatus.ACTIVE,
expiresDate: expiresDate ? new Date(expiresDate) : null,
renewalDate: new Date(transaction.purchaseDate),
isInBillingRetry: false,
gracePeriodExpiresAt: null,
autoRenewStatus: renewalInfo?.autoRenewStatus === 1,
price: transaction.price,
currency: transaction.currency,
},
})
}
async function handleFailedRenewal(
originalTransactionId: string,
subtype?: string,
renewalInfo?: DecodedRenewalInfo
): Promise<void> {
const status = subtype === 'GRACE_PERIOD'
? SubscriptionStatus.GRACE_PERIOD
: SubscriptionStatus.BILLING_RETRY
await prisma.subscription.update({
where: { originalTransactionId },
data: {
status,
isInBillingRetry: true,
gracePeriodExpiresAt: renewalInfo?.gracePeriodExpiresDate
? new Date(renewalInfo.gracePeriodExpiresDate)
: null,
},
})
}
async function handleGracePeriodExpired(
originalTransactionId: string
): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
status: SubscriptionStatus.BILLING_RETRY,
gracePeriodExpiresAt: null,
},
})
}
async function handleExpired(
originalTransactionId: string,
subtype?: string
): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
status: SubscriptionStatus.EXPIRED,
isInBillingRetry: false,
gracePeriodExpiresAt: null,
},
})
}
async function handleRenewalStatusChange(
originalTransactionId: string,
subtype?: string,
renewalInfo?: DecodedRenewalInfo
): Promise<void> {
const autoRenewStatus = subtype === 'AUTO_RENEW_ENABLED'
await prisma.subscription.update({
where: { originalTransactionId },
data: {
autoRenewStatus,
autoRenewProductId: renewalInfo?.autoRenewProductId,
},
})
}
async function handleRenewalPrefChange(
originalTransactionId: string,
renewalInfo?: DecodedRenewalInfo
): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
autoRenewProductId: renewalInfo?.autoRenewProductId,
},
})
}
async function handleRefund(
originalTransactionId: string,
transaction: DecodedTransaction
): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
status: SubscriptionStatus.REVOKED,
cancellationDate: new Date(),
cancellationReason: 0, // Refund
},
})
}
async function handleRevoke(originalTransactionId: string): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
status: SubscriptionStatus.REVOKED,
cancellationDate: new Date(),
},
})
}
async function handleRenewalExtended(
originalTransactionId: string,
transaction: DecodedTransaction
): Promise<void> {
await prisma.subscription.update({
where: { originalTransactionId },
data: {
expiresDate: transaction.expiresDate
? new Date(transaction.expiresDate)
: null,
},
})
}
// src/routes/subscriptions.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import { prisma } from '../lib/db'
import { authMiddleware } from '../middleware/auth'
import { getSubscriptionStatus, decodeTransaction, decodeRenewalInfo } from '../lib/appstore'
export const subscriptionRoutes = new Hono()
subscriptionRoutes.use('*', authMiddleware)
// Verify and store new purchase from app
const verifySchema = z.object({
originalTransactionId: z.string(),
environment: z.enum(['Sandbox', 'Production']),
})
subscriptionRoutes.post('/verify', zValidator('json', verifySchema), async (c) => {
const userId = c.get('userId')
const { originalTransactionId, environment } = c.req.valid('json')
try {
// Get subscription status from Apple
const statusResponse = await getSubscriptionStatus(originalTransactionId, environment)
if (!statusResponse.data || statusResponse.data.length === 0) {
return c.json({ error: 'No subscription found' }, 404)
}
// Find the latest transaction
const latestTransaction = statusResponse.data[0].lastTransactions[0]
const transaction = await decodeTransaction(latestTransaction.signedTransactionInfo)
const renewalInfo = latestTransaction.signedRenewalInfo
? await decodeRenewalInfo(latestTransaction.signedRenewalInfo)
: undefined
// Map Apple status to our status
const status = mapAppleStatus(latestTransaction.status, renewalInfo)
// Upsert subscription
const subscription = await prisma.subscription.upsert({
where: { originalTransactionId },
create: {
userId,
originalTransactionId,
transactionId: transaction.transactionId,
productId: transaction.productId,
status,
environment,
purchaseDate: new Date(transaction.purchaseDate),
expiresDate: transaction.expiresDate ? new Date(transaction.expiresDate) : null,
autoRenewStatus: renewalInfo?.autoRenewStatus === 1,
autoRenewProductId: renewalInfo?.autoRenewProductId,
isInBillingRetry: renewalInfo?.isInBillingRetryPeriod || false,
gracePeriodExpiresAt: renewalInfo?.gracePeriodExpiresDate
? new Date(renewalInfo.gracePeriodExpiresDate)
: null,
price: transaction.price,
currency: transaction.currency,
},
update: {
userId, // Update user association
transactionId: transaction.transactionId,
productId: transaction.productId,
status,
expiresDate: transaction.expiresDate ? new Date(transaction.expiresDate) : null,
autoRenewStatus: renewalInfo?.autoRenewStatus === 1,
autoRenewProductId: renewalInfo?.autoRenewProductId,
isInBillingRetry: renewalInfo?.isInBillingRetryPeriod || false,
gracePeriodExpiresAt: renewalInfo?.gracePeriodExpiresDate
? new Date(renewalInfo.gracePeriodExpiresDate)
: null,
price: transaction.price,
currency: transaction.currency,
},
})
return c.json({
status: subscription.status,
productId: subscription.productId,
expiresDate: subscription.expiresDate,
autoRenewStatus: subscription.autoRenewStatus,
isInGracePeriod: subscription.status === 'GRACE_PERIOD',
})
} catch (error: any) {
console.error('Verification error:', error)
return c.json({ error: 'Verification failed' }, 500)
}
})
// Get current subscription status
subscriptionRoutes.get('/status', async (c) => {
const userId = c.get('userId')
const subscription = await prisma.subscription.findUnique({
where: { userId },
})
if (!subscription) {
return c.json({
hasSubscription: false,
status: null,
})
}
// Check if needs refresh
const needsRefresh = subscription.status === 'ACTIVE' &&
subscription.expiresDate &&
subscription.expiresDate < new Date()
if (needsRefresh) {
// Refresh from Apple
try {
const statusResponse = await getSubscriptionStatus(
subscription.originalTransactionId,
subscription.environment as 'Production' | 'Sandbox'
)
if (statusResponse.data?.[0]?.lastTransactions?.[0]) {
const latestTransaction = statusResponse.data[0].lastTransactions[0]
const transaction = await decodeTransaction(latestTransaction.signedTransactionInfo)
const renewalInfo = latestTransaction.signedRenewalInfo
? await decodeRenewalInfo(latestTransaction.signedRenewalInfo)
: undefined
const newStatus = mapAppleStatus(latestTransaction.status, renewalInfo)
await prisma.subscription.update({
where: { id: subscription.id },
data: {
status: newStatus,
expiresDate: transaction.expiresDate ? new Date(transaction.expiresDate) : null,
},
})
subscription.status = newStatus
subscription.expiresDate = transaction.expiresDate
? new Date(transaction.expiresDate)
: null
}
} catch (error) {
console.error('Status refresh error:', error)
}
}
return c.json({
hasSubscription: true,
status: subscription.status,
productId: subscription.productId,
expiresDate: subscription.expiresDate,
autoRenewStatus: subscription.autoRenewStatus,
isInGracePeriod: subscription.status === 'GRACE_PERIOD',
isInBillingRetry: subscription.isInBillingRetry,
})
})
function mapAppleStatus(
appleStatus: number,
renewalInfo?: DecodedRenewalInfo
): SubscriptionStatus {
switch (appleStatus) {
case 1: // Active
return SubscriptionStatus.ACTIVE
case 2: // Expired
return SubscriptionStatus.EXPIRED
case 3: // Billing retry
return SubscriptionStatus.BILLING_RETRY
case 4: // Grace period
return SubscriptionStatus.GRACE_PERIOD
case 5: // Revoked
return SubscriptionStatus.REVOKED
default:
return SubscriptionStatus.EXPIRED
}
}
For apps supporting iOS < 15:
// src/lib/appstore/receipt.ts
const PRODUCTION_URL = 'https://buy.itunes.apple.com/verifyReceipt'
const SANDBOX_URL = 'https://sandbox.itunes.apple.com/verifyReceipt'
interface ReceiptValidationResponse {
status: number
environment: string
receipt: any
latest_receipt_info?: any[]
pending_renewal_info?: any[]
}
export async function validateReceipt(
receiptData: string,
excludeOldTransactions = true
): Promise<ReceiptValidationResponse> {
const body = {
'receipt-data': receiptData,
'password': process.env.APPSTORE_SHARED_SECRET,
'exclude-old-transactions': excludeOldTransactions,
}
// Try production first
let response = await fetch(PRODUCTION_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
let result: ReceiptValidationResponse = await response.json()
// If sandbox receipt, retry with sandbox URL
if (result.status === 21007) {
response = await fetch(SANDBOX_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
result = await response.json()
}
return result
}
// Status codes
// 0: Valid
// 21000: App Store could not read JSON
// 21002: Malformed receipt data
// 21003: Receipt authentication failed
// 21004: Shared secret mismatch
// 21005: Server unavailable
// 21006: Valid but subscription expired
// 21007: Sandbox receipt sent to production
// 21008: Production receipt sent to sandbox
// 21010: Account not found
// src/middleware/subscription.ts
import { Context, Next } from 'hono'
import { prisma } from '../lib/db'
import { SubscriptionStatus } from '@prisma/client'
export async function requireSubscription(c: Context, next: Next) {
const userId = c.get('userId')
const subscription = await prisma.subscription.findUnique({
where: { userId },
})
const hasAccess = subscription && (
subscription.status === SubscriptionStatus.ACTIVE ||
subscription.status === SubscriptionStatus.GRACE_PERIOD ||
(subscription.status === SubscriptionStatus.BILLING_RETRY &&
subscription.gracePeriodExpiresAt &&
subscription.gracePeriodExpiresAt > new Date())
)
if (!hasAccess) {
return c.json({ error: 'Subscription required' }, 403)
}
c.set('subscription', subscription)
await next()
}
// Check specific product tier
export function requireTier(requiredProducts: string[]) {
return async (c: Context, next: Next) => {
const subscription = c.get('subscription')
if (!subscription || !requiredProducts.includes(subscription.productId)) {
return c.json({ error: 'Higher tier required' }, 403)
}
await next()
}
}
// Protect premium routes
app.use('/api/premium/*', authMiddleware, requireSubscription)
// Protect specific tier
app.use('/api/pro/*', authMiddleware, requireSubscription, requireTier(['pro_monthly', 'pro_yearly']))
const { testNotificationToken } = await requestTestNotification()
console.log('Test token:', testNotificationToken)
In sandbox, subscriptions renew faster: | Duration | Sandbox Duration | |----------|------------------| | 1 week | 3 minutes | | 1 month | 5 minutes | | 2 months | 10 minutes | | 3 months | 15 minutes | | 6 months | 30 minutes | | 1 year | 1 hour |
# App Store Connect API
APPSTORE_KEY_ID="XXXXXXXXXX"
APPSTORE_ISSUER_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
APPSTORE_KEY_PATH="./keys/SubscriptionKey_XXX.p8"
APPSTORE_BUNDLE_ID="com.yourcompany.app"
# Legacy receipt validation
APPSTORE_SHARED_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
□ .p8 key file securely stored
□ Environment variables configured
□ Webhook endpoints registered in App Store Connect
□ Version 2 notifications selected
□ Both production and sandbox webhooks configured
□ Database migrations applied
□ JWS verification working
□ Grace period handling implemented
□ Refund handling implemented
□ Monitoring for failed webhook processing
□ Retry logic for failed events
requestTestNotification() to testdevelopment
Design principles for building polished, native-feeling SwiftUI apps and widgets. Use this skill when creating or modifying SwiftUI views, iOS widgets (WidgetKit), or any native Apple UI. Ensures proper spacing, typography, colors, and widget implementations that look and feel like quality apps rather than AI-generated slop.
data-ai
Design and implement SwiftUI views, components, and app architecture. Use when creating new SwiftUI views, implementing MVVM/TCA patterns, managing state with @Observable, @State, @Binding, or @Environment, designing navigation flows, or structuring iOS app architecture. Triggers on SwiftUI, view model, state management, navigation, coordinator pattern.
development
Implement, review, or improve SwiftUI animations and transitions. Use when adding implicit or explicit animations with withAnimation, configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
testing
Audit SwiftUI views for accessibility (iOS + macOS) with patch-ready fixes