Validate Apple App Store purchases This guide shows you how to set up and validate Apple App Store purchases and subscriptions with Nakama.
Before you begin
# Make sure you have:
An Apple Developer account with access to App Store Connect An iOS app configured for in-app purchases in App Store Connect How Apple validation works
# Nakama sends Apple purchase receipts to Apple for validation. Following Apple’s recommendations, it uses both Production and Sandbox servers to validate receipts.
Apple receipts can contain multiple purchases. Nakama will validate all of them and store them as individual purchase records. Nakama only supports validating iOS 7+ receipts.
To validate receipts against the App Store, you’ll need your app’s shared secret.
From App Store Connect navigate to the General > App Information page and the App-Specific Shared Secret section: Apple App Store Connect
Select Manage and in the dialog that appears, select Generate : Generate Apple App Store Connect Shared Secret
Make a record of your shared secret for use in your Nakama configuration: New Apple App Store Connect Shared Secret
Set the value of Nakama’s iap.apple.shared_password configuration flag to the value of the shared secret created above. 1
2
3
iap :
apple :
shared_password : "your-shared-secret-here"
Validate a purchase
# To validate an Apple purchase, you’ll send the base64-encoded receipt data to Nakama’s validation endpoint. Nakama verifies the receipt with Apple’s servers and returns a list of validated purchases. Each purchase includes a “seen before” flag to help you detect and prevent replay attacks.
Client
1
2
3
curl "http://127.0.0.1:7350/v2/iap/purchase/apple \
--user 'defaultkey:' \
--data '{" receipt":" base64_encoded_receipt_data"}'
Client
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local function validate_receipt ( receipt )
local result = client.validate_purchase_apple ( receipt )
if result.error then
print ( result.message )
return
end
pprint ( result )
end
-- Use https://defold.com/extension-iap/
iap.set_listener ( function ( self , transaction , error )
if not error then
validate_receipt ( transaction.receipt )
end
end )
iap.buy ( "com.defold.nakama.goldbars-10" )
Client
1
2
3
4
5
6
string appleReceipt = "<receipt>" ;
var response = await client . ValidatePurchaseAppleAsync ( session , appleReceipt );
foreach ( var validatedPurchase in response . ValidatedPurchases )
{
System . Console . WriteLine ( "Validated purchase: " + validatedPurchase );
}
Client
1
2
3
4
5
6
7
let appleReceipt = "<receipt>"
let response = try await client . validatePurchaseApple ( session : session , receipt : appleReceipt )
for purchase in response . validatedPurchases {
print ( "Validated purchase: \( purchase ) " )
}
Code snippet for this language Dart/Flutter has not been found. Please choose another language to show equivalent examples.
Client
1
2
3
4
5
const appleReceipt = "<receipt>" ;
const result = await client . validatePurchaseApple ( session , appleReceipt );
result . validatedPurchases . forEach (( validatedPurchase ) => {
console . info ( "Validated purchase:" , validatedPurchase );
});
Client
1
2
3
4
5
6
7
var apple_receipt = "<receipt>"
var result : NakamaAPI . ApiValidatePurchaseResponse = yield ( client . validate_purchase_apple_async ( session , apple_receipt ), "completed" )
if result . is_exception ():
print ( "An error occurred: %s " % result )
return
for p in result . validated_purchase :
print ( "Validated purchase: %s " % p . validated_purchase )
Client
1
2
3
4
5
6
7
var apple_receipt = "<receipt>"
var result : NakamaAPI . ApiValidatePurchaseResponse = await client . validate_purchase_apple_async ( session , apple_receipt )
if result . is_exception ():
print ( "An error occurred: %s " % result )
return
for p in result . validated_purchase :
print ( "Validated purchase: %s " % p . validated_purchase )
Client
1
2
3
4
5
6
7
8
POST /v2/iap/purchase/apple
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
{
"receipt" : "base64_encoded_Apple_receipt_payload"
}
Code snippet for this language C++/Unreal/Cocos2d-x has not been found. Please choose another language to show equivalent examples.
Code snippet for this language Java/Android has not been found. Please choose another language to show equivalent examples.
Refer to the function reference page for the provided runtime purchase validation functions .
Validate a subscription
# Subscription validation works similarly to purchase validation, but returns subscription-specific information like expiry time and active status. You’ll send the same base64-encoded receipt, but to the subscription validation endpoint.
Client
1
2
3
4
string appleReceipt = "<receipt>" ;
var response = await client . ValidateSubscriptionAppleAsync ( session , appleReceipt );
System . Console . WriteLine ( "Validated subscription: " + response . ValidatedSubscription );
Client
1
2
3
4
5
let appleReceipt = "<receipt>"
let response = try await client . validateSubscriptionApple ( session : session , receipt : appleReceipt )
print ( "Validated subscription: \( response . validatedSubscription ) " )
Code snippet for this language Dart/Flutter has not been found. Please choose another language to show equivalent examples.
Client
1
2
3
4
5
6
7
8
var apple_receipt = "..."
var response : NakamaAPI . ApiValidateSubscriptionResponse = yield ( client . validate_subscription_apple_async ( session , apple_receipt ), "completed" )
if response . is_exception ():
print ( "An error occurred: %s " % response )
return
print ( "Validated subscription: %s " % response . validated_subscription )
Client
1
2
3
4
5
6
7
8
var apple_receipt = "..."
var response : NakamaAPI . ApiValidateSubscriptionResponse = await client . validate_subscription_apple_async ( session , apple_receipt )
if response . is_exception ():
print ( "An error occurred: %s " % response )
return
print ( "Validated subscription: %s " % response . validated_subscription )
Client
1
2
3
curl "http://127.0.0.1:7350/v2/iap/subscription/apple \
--user 'defaultkey:' \
--data '{" receipt":" base64_encoded_receipt_data"}'
Client
1
2
3
4
5
6
7
8
POST /v2/iap/subscription/apple
Host: 127.0.0.1:7350
Accept: application/json
Content-Type: application/json
Authorization: Bearer <session token>
{
"receipt" : "base64_encoded_Apple_receipt_payload"
}
Client
1
2
3
4
5
local apple_receipt = "<receipt>" ;
local response = client.validate_purchase_apple ( receipt )
for i , purchase in ipairs ( response.validated_purchase ) do
pprint ( "Validated purchase: " , purchase )
end
Client
1
2
3
const appleReceipt = "<receipt>" ;
const result = await client . validateSubscriptionApple ( session , appleReceipt );
console . info ( "Validated subscription:" , result . validatedSubscription );
Code snippet for this language C++/Unreal/Cocos2d-x has not been found. Please choose another language to show equivalent examples.
Code snippet for this language Java/Android has not been found. Please choose another language to show equivalent examples.
Refer to the function reference page for the provided runtime subscription validation functions .
Handle refunds and subscription changes
# The Apple App Store supports Server Notifications to monitor IAP state changes in real-time. Nakama can receive these notifications via a callback URL to track subscription renewals, expirations, cancellations, and refunds automatically.
Set up notification callbacks
# To activate the callback URL, set the notifications_endpoint_id configuration, which creates the following endpoint path: /v2/console/apple/subscriptions/<notifications_endpoint_id>.
Configure this URL in App Store Connect for both production and sandbox environments.
App Store Connect Production Server URL
Automatic state updates
# Once you’ve configured the callback URL, Nakama automatically updates the state of any purchase or subscription that was previously validated through the validation APIs above. This keeps subscription expiry times, active status, and other metadata synchronized with the App Store.
Notifications only work for purchases and subscriptions that you’ve already validated through Nakama’s client APIs. If Apple sends a notification for a purchase Nakama hasn’t seen before, it’ll be ignored.
Notification types
# Nakama normalizes Apple’s notifications into 5 simplified types:
Type Description SUBSCRIBEDInitial subscription purchase or resubscription RENEWEDSubscription successfully auto-renewed EXPIREDSubscription expired and will not renew CANCELLEDSubscription cancelled by user or App Store REFUNDEDPurchase or subscription refunded
These normalized types make it easier to handle notifications consistently across both Apple and Google platforms.
Implement notification hooks
# You can register custom code to respond to notifications for purchases and subscriptions. The hooks fire after Nakama has automatically updated the purchase/subscription state in the database.
Use these hooks to implement your own business logic when IAP states change—like revoking player access when a subscription expires, sending analytics events, or triggering in-game rewards for renewals.
Purchase notifications
# Register a hook to handle purchase refunds. The purchase parameter contains the validated purchase data. See Response contents for available fields.
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if err := initializer . RegisterPurchaseNotificationApple ( func ( ctx context . Context , logger runtime . Logger , db * sql . DB , nk runtime . NakamaModule , notificationType runtime . NotificationType , purchase * api . ValidatedPurchase , payload * runtime . AppleNotificationData ) error {
logger . Info ( "Apple purchase notification: %s" , notificationType . String ())
// For purchases, notificationType will always be REFUNDED since that's the only
// purchase notification Apple sends.
if notificationType == runtime . IAPNotificationRefunded {
// Handle refund - e.g., revoke access, update analytics
logger . Info ( "Purchase refunded for user: %s, product: %s" , purchase . UserId , purchase . ProductId )
// payload contains detailed Apple notification data if needed for custom processing
}
return nil
}); err != nil {
return err
}
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
let registerPurchaseNotificationApple : nkruntime.RegisterPurchaseNotificationAppleFunction =
function (
ctx : nkruntime.Context ,
logger : nkruntime.Logger ,
nk : nkruntime.Nakama ,
notificationType : nkruntime.NotificationType ,
purchase : nkruntime.ValidatedPurchase ,
payload : nkruntime.AppleNotificationData
) {
logger . info ( "Apple purchase notification: %s" , notificationType );
if ( notificationType == nkruntime . NotificationType . REFUNDED ) {
logger . info (
"Purchase refunded for user: %s, product: %s" ,
purchase . userId ,
purchase . productId
);
// Handle refund logic
}
};
initializer . registerPurchaseNotificationApple (
registerPurchaseNotificationApple
);
Server
1
2
3
4
5
6
7
8
9
10
local function purchase_notification_apple ( context , logger , nk , notification_type , purchase , payload )
logger.info (( "Apple purchase notification: %s" ): format ( notification_type ))
if notification_type == "REFUNDED" then
logger.info (( "Purchase refunded for user: %s, product: %s" ): format ( purchase.user_id , purchase.product_id ))
-- Handle refund logic
end
end
nk.register_purchase_notification_apple ( purchase_notification_apple )
Subscription notifications
# Register a hook to handle all subscription state changes. The subscription parameter contains the validated subscription data - see Response contents for available fields.
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
if err := initializer . RegisterSubscriptionNotificationApple ( func ( ctx context . Context , logger runtime . Logger , db * sql . DB , nk runtime . NakamaModule , notificationType runtime . NotificationType , subscription * api . ValidatedSubscription , payload * runtime . AppleNotificationData ) error {
logger . Info ( "Apple subscription notification: %s" , notificationType . String ())
// Handle different notification types
switch notificationType {
case runtime . IAPNotificationSubscribed :
logger . Info ( "New subscription: %s" , subscription . ProductId )
// Handle new subscription - e.g., grant access, send welcome message
case runtime . IAPNotificationRenewed :
logger . Info ( "Subscription renewed: %s" , subscription . ProductId )
// Handle renewal - e.g., extend access, update analytics
case runtime . IAPNotificationExpired :
logger . Info ( "Subscription expired: %s" , subscription . ProductId )
// Handle expiration - e.g., revoke access, send re-engagement message
case runtime . IAPNotificationCancelled :
logger . Info ( "Subscription cancelled: %s" , subscription . ProductId )
// Handle cancellation - e.g., schedule access revocation, send feedback survey
case runtime . IAPNotificationRefunded :
logger . Info ( "Subscription refunded: %s" , subscription . ProductId )
// Handle refund - e.g., immediately revoke access, update analytics
}
// The subscription object already contains updated state (expiry time, etc.)
// payload contains detailed Apple notification data for custom processing if needed
return nil
}); err != nil {
return err
}
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
let registerSubscriptionNotificationApple : nkruntime.RegisterSubscriptionNotificationAppleFunction =
function (
ctx : nkruntime.Context ,
logger : nkruntime.Logger ,
nk : nkruntime.Nakama ,
notificationType : nkruntime.NotificationType ,
subscription : nkruntime.ValidatedSubscription ,
payload : nkruntime.AppleNotificationData
) {
logger . info ( "Apple subscription notification: %s" , notificationType );
switch ( notificationType ) {
case nkruntime.NotificationType.SUBSCRIBED :
logger.info ( "New subscription: %s" , subscription . productId );
break ;
case nkruntime.NotificationType.RENEWED :
logger.info ( "Subscription renewed: %s" , subscription . productId );
break ;
case nkruntime.NotificationType.EXPIRED :
logger.info ( "Subscription expired: %s" , subscription . productId );
break ;
case nkruntime.NotificationType.CANCELLED :
logger.info ( "Subscription cancelled: %s" , subscription . productId );
break ;
case nkruntime.NotificationType.REFUNDED :
logger.info ( "Subscription refunded: %s" , subscription . productId );
break ;
}
};
initializer . registerSubscriptionNotificationApple (
registerSubscriptionNotificationApple
);
Server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local function subscription_notification_apple ( context , logger , nk , notification_type , subscription , payload )
logger.info (( "Apple subscription notification: %s" ): format ( notification_type ))
if notification_type == "SUBSCRIBED" then
logger.info (( "New subscription: %s" ): format ( subscription.product_id ))
elseif notification_type == "RENEWED" then
logger.info (( "Subscription renewed: %s" ): format ( subscription.product_id ))
elseif notification_type == "EXPIRED" then
logger.info (( "Subscription expired: %s" ): format ( subscription.product_id ))
elseif notification_type == "CANCELLED" then
logger.info (( "Subscription cancelled: %s" ): format ( subscription.product_id ))
elseif notification_type == "REFUNDED" then
logger.info (( "Subscription refunded: %s" ): format ( subscription.product_id ))
end
end
nk.register_subscription_notification_apple ( subscription_notification_apple )
See also
#