# Validate Apple App Store purchases

**URL:** https://heroiclabs.com/docs/nakama/concepts/iap-validation/apple/
**Summary:** Step-by-step guide to configuring and validating Apple App Store purchases and subscriptions with Nakama, including handling refunds and subscription changes through notifications.
**Keywords:** iap, in-app purchases, validation, apple, iOS, app store
**Categories:** nakama, apple, iap-validation

---


# 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
- Nakama v3.37.0 or later if using StoreKit 2

## 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 validates all of them and stores them as individual purchase records. Nakama only supports validating iOS 7+ receipts.

### StoreKit 1 and StoreKit 2

Nakama supports both StoreKit 1 (legacy) and StoreKit 2 receipts through the same validation API. The receipt type your app sends depends on which version of the Apple SDK your client uses.

| Setting                | StoreKit 1 (legacy)                  | StoreKit 2                    |
| ---------------------- | ------------------------------------ | ----------------------------- |
| Validation method      | App-specific shared secret           | PKI (no shared secret needed) |
| Receipt format         | Base64-encoded receipt               | JWS-signed transaction        |
| Nakama config required | Yes, set `iap.apple.shared_password` | No                            |

If you set the shared secret in Nakama, it's used only for StoreKit 1 receipts. StoreKit 2 receipts are validated using PKI regardless of whether the shared secret is set.

## Configure Apple App Store credentials

If you're using StoreKit 2 exclusively, skip this section as no Nakama configuration is required. If you're using StoreKit 1 or need to support both receipt formats, configure the shared secret as follows.

1. From [App Store Connect](https://appstoreconnect.apple.com), go to
   **General** > **App Information** and find the **App-Specific Shared Secret**
   section:

{{< screenshot src="images/pages/nakama/concepts/iap-validation/apple_iap_1.png" alt="Apple App Store Connect" width="80%" >}}

2. Select **Manage** and in the dialog that appears, select **Generate**:

{{< screenshot src="images/pages/nakama/concepts/iap-validation/apple_iap_2.png" alt="Generate Apple App Store Connect Shared Secret" width="80%" >}}

3. Record your shared secret for use in your Nakama configuration:

{{< screenshot src="images/pages/nakama/concepts/iap-validation/apple_iap_3.png" alt="New Apple App Store Connect Shared Secret" width="80%" >}}

4. Set Nakama's `iap.apple.shared_password`
   [configuration flag](../../../getting-started/configuration/#apple) to the
   shared secret value:

```yaml
iap:
  apple:
    shared_password: "your-shared-secret-here"
```

## Validate a purchase

To validate an Apple purchase, send the receipt data in the appropriate format 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.

{{< code type="client" >}}

```bash
curl "http://127.0.0.1:7350/v2/iap/purchase/apple \
  --user 'defaultkey:' \
  --data '{"receipt":"base64_encoded_receipt_data"}'
```

{{< / code >}}

{{< code type="client" framework="defold" >}}

```lua
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")
```

{{< / code >}}

{{< code type="client" >}}

```csharp
string appleReceipt = "<receipt>";
var response = await client.ValidatePurchaseAppleAsync(session, appleReceipt);
foreach (var validatedPurchase in response.ValidatedPurchases)
{
    System.Console.WriteLine("Validated purchase: " + validatedPurchase);
}
```

{{< / code >}}

{{< code type="client" >}}

```swift
let appleReceipt = "<receipt>"

let response = try await client.validatePurchaseApple(session: session, receipt: appleReceipt)

for purchase in response.validatedPurchases {
    print("Validated purchase: \(purchase)")
}
```

{{< / code >}}

{{< missing type="client" lang="dart" />}}

{{< code type="client" >}}

```javascript
const appleReceipt = "<receipt>";
const result = await client.validatePurchaseApple(session, appleReceipt);
result.validatedPurchases.forEach((validatedPurchase) => {
  console.info("Validated purchase:", validatedPurchase);
});
```

{{< / code >}}

{{< code type="client" framework="godot3" >}}

```gdscript
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)
```

{{< / code >}}

{{< code type="client" framework="godot4" >}}

```gdscript
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)
```

{{< / code >}}

{{< code type="client" >}}

```shell
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 >}}

{{< missing type="client" lang="cpp" />}}
{{< missing type="client" lang="java" />}}

Refer to the function reference page for the provided runtime [purchase validation functions](../../../server-framework/typescript-runtime/function-reference/#purchaseValidateApple).

## Validate a subscription

Subscription validation works similarly to purchase validation, but returns subscription-specific information like expiry time and active status. Send the same receipt in the appropriate format to the subscription validation endpoint.

{{< code type="client" >}}

```csharp
string appleReceipt = "<receipt>";
var response = await client.ValidateSubscriptionAppleAsync(session, appleReceipt);

System.Console.WriteLine("Validated subscription: " + response.ValidatedSubscription);
```

{{< / code >}}

{{< code type="client" >}}

```swift
let appleReceipt = "<receipt>"

let response = try await client.validateSubscriptionApple(session: session, receipt: appleReceipt)

print("Validated subscription: \(response.validatedSubscription)")
```

{{< / code >}}

{{< missing type="client" lang="dart" />}}

{{< code type="client" framework="godot3" >}}

```gdscript
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)
```

{{< / code >}}

{{< code type="client" framework="godot4" >}}

```gdscript
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)
```

{{< / code >}}

{{< code type="client" >}}

```bash
curl "http://127.0.0.1:7350/v2/iap/subscription/apple \
  --user 'defaultkey:' \
  --data '{"receipt":"base64_encoded_receipt_data"}'
```

{{< / code >}}

{{< code type="client" >}}

```shell
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"
}
```

{{< / code >}}

{{< code type="client" framework="defold" >}}

```lua

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

{{< / code >}}

{{< code type="client" >}}

```javascript
const appleReceipt = "<receipt>";
const result = await client.validateSubscriptionApple(session, appleReceipt);
console.info("Validated subscription:", result.validatedSubscription);
```

{{< / code >}}

{{< missing type="client" lang="cpp" />}}
{{< missing type="client" lang="java" />}}

Refer to the function reference page for the provided runtime [subscription validation functions](../../../server-framework/typescript-runtime/function-reference/#subscriptionValidateApple).

## Handle refunds and subscription changes

The Apple App Store supports [Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications) to monitor IAP state changes in real time. Configuring server notifications is **optional** since your receipts and subscriptions validate correctly without it. However, enabling notifications is recommended: it lets Nakama automatically track subscription renewals, expirations, cancellations, and refunds, and allows you to register runtime hooks to trigger custom code when these events occur.

{{< note "important" "Accepting incoming notifications" >}}
Whether Nakama can process an incoming notification depends on which version of
StoreKit your client uses.

**StoreKit 1:** Nakama can only process notifications for purchases that were
previously validated through its client APIs. If Apple sends a notification for
a purchase Nakama hasn't seen before, it's ignored.

**StoreKit 2:** Nakama can process a notification if either of the following
is true:

- The purchase was previously validated through Nakama's client APIs, _or_
- `appAccountToken` was set to the Nakama `user_id` (as a UUID) during the
  client purchase flow, using StoreKit 2's
  [`appAccountToken(_:)`](<https://developer.apple.com/documentation/storekit/product/purchaseoption/appaccounttoken(_:)>)
  purchase option.

If neither condition is met, the notification is ignored.

Even if you set `appAccountToken`, validating purchases through Nakama's client
APIs is still recommended. Server notifications are asynchronous, so there's no
guarantee of how quickly Nakama would otherwise become aware of an active
subscription that hasn't been explicitly validated.
{{< / note >}}

### Set up notification callbacks

To activate the callback URL, set the [notifications_endpoint_id](../../../getting-started/configuration/#apple.notifications_endpoint_id) configuration, which creates the following endpoint path: `/v2/console/apple/subscriptions/<notifications_endpoint_id>`.

The `<notifications_endpoint_id>` is an arbitrary string you define. It becomes the path segment Nakama listens on for incoming notifications.

Configure this URL in [App Store Connect](https://help.apple.com/app-store-connect/#/dev0067a330b) for both production and sandbox environments. Apple requires [App Store Server Notifications v2](https://developer.apple.com/documentation/appstoreservernotifications/enabling-app-store-server-notifications).

![App Store Connect Production Server URL](images/apple_iap_production_server_url.png)

### 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. This keeps subscription expiry times, active status, and other metadata synchronized with the App Store.

### Notification types

Nakama normalizes Apple's notifications into five simplified types:

| Type         | Description                                         |
| ------------ | --------------------------------------------------- |
| `SUBSCRIBED` | Initial subscription purchase or resubscription     |
| `RENEWED`    | Subscription successfully auto-renewed              |
| `EXPIRED`    | Subscription expired and won't renew                |
| `CANCELLED`  | Subscription cancelled by the user or the App Store |
| `REFUNDED`   | Purchase or subscription refunded                   |

These normalized types make it easier to handle notifications consistently across both Apple and Google platforms.

### Implement notification hooks

Register custom code to respond to notifications for purchases and subscriptions. Hooks fire **after** Nakama has automatically updated the purchase or 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](../#response-contents) for available fields.

{{< code type="server" >}}

```go
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
}
```

{{< / code >}}

{{< code type="server" >}}

```typescript
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,
);
```

{{< / code >}}

{{< code type="server" >}}

```lua
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)
```

{{< / code >}}

#### Subscription notifications

Register a hook to handle all subscription state changes. The `subscription` parameter contains the validated subscription data — see [Response contents](../#response-contents) for available fields.

{{< code type="server" >}}

```go
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())

    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
}
```

{{< / code >}}

{{< code type="server" >}}

```typescript
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,
);
```

{{< / code >}}

{{< code type="server" >}}

```lua
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)
```

{{< / code >}}

## See also

- [About in-app purchase validation](../#)
- [Validate Google Play purchases](../google/)
- [Purchases function reference](../../../server-framework/go-runtime/function-reference/#Purchases)
