# In-app purchases and subscriptions

When someone writes to support from your app, your team and the AI assistant should know what they pay for: which plan, whether it renews, when it ends, whether it was refunded. Ha Tang records subscriptions and one-time purchases (App Store, Google Play and your own website) on the customer's profile and keeps them current.

Your app still sells with StoreKit or Google Play Billing (or `in_app_purchase` / `react-native-iap`). Ha Tang only needs to hear about each purchase, renewal, expiry and refund.

## Choose how purchases reach Ha Tang {#choose}

| | A. From your app, stores connected | B. From your backend |
|---|---|---|
| Who checks the purchase | Ha Tang, with Apple and Google | Your server, as it does today |
| What you add | `purchaseToken` and `reportPurchase` in the app; the stores' notifications go to Ha Tang | `chat.subscriptions.upsert` and `chat.purchases.upsert` from your server after each change |
| Renewals, expiries, refunds | From the stores' notifications to Ha Tang | Your server sends them as it learns them |
| Web sales | Sent from your backend (as in B) | Included |
| Suits | Apps whose servers do not check store purchases yet | Apps whose servers already check purchases, keep the stores' notification URLs, or also sell on the web |

Apple allows one App Store Server Notifications URL per environment, so if your server already receives them, choose B. Both can run together: the same purchase from both sides stays one record.

## Path A: how it fits together {#overview}

```
Your app ──purchase(appAccountToken = purchaseToken(user))──▶ App Store / Google Play
   │                                                                │
   └─ reportPurchase(proof) ──▶ Ha Tang ◀── renewals, cancellations, refunds (store notifications)
                                   │
                                   └─ verifies with the store, records it on the customer
```

1. **At purchase time** the app passes `purchaseToken(userId)` to the store. The store sends it back in every notification about that purchase, so renewals, cancellations and refunds land on the right customer months later.
2. **After a purchase** the app calls `reportPurchase` with the store's proof. Ha Tang checks it with Apple or Google and the plan shows on the customer at once, without waiting for the store's notification.
3. **Afterwards** the store notifications keep the plan current. With the App Store Server API key, Ha Tang also re-checks plans about to renew every day.

## Before you start {#before-you-start}

For both paths:

- **The chat in your app and website**, signing people in with `identify` and the user id your backend knows them by (see [Mobile apps](https://hatang.dev/docs/guides/support/mobile-apps.md)). Plans and purchases land on the customer through that id.
- **Products** in the dashboard with the kind (subscription, consumable, one-time), billing period, trial, the App Store / Google Play product ids and prices, and a **SKU** for anything sold on the web. Product cards in the chat then show the store price, the assistant knows your plans, and records find their product. See [Selling software and subscriptions](https://hatang.dev/help/products/software-subscriptions).

For path A:

- **The chat SDK** with the purchase calls: `@hatang/chat-react-native` 0.4.0+, `hatang_chat` 0.6.0+, `dev.hatang:chat` 0.4.0+ or `HatangChat` 0.4.0+.
- **The stores connected** to the app in the dashboard (**Channels → your app → Subscriptions from the App Store and Google Play**):
  - **App Store**: the notification URL pasted for Production and Sandbox in App Store Connect; optionally the App Store Server API key (issuer id, key id, `.p8`), which lets Ha Tang refresh plans, re-check them daily and import existing subscribers.
  - **Google Play**: a service account JSON with access to financial data in Play Console, and a Pub/Sub push subscription to the URL the dashboard shows, with real-time developer notifications turned on. Without the service account Ha Tang cannot verify or acknowledge Android purchases.

The same help article walks through both stores step by step.

For path B:

- **An API key** with the `crm:write` scope, kept on your server.
- **A server SDK**: `@hatang/sdk` 0.6.0+ ([Node.js](https://hatang.dev/docs/guides/sdks/node.md)) or `hatang` 0.1.0+ ([Python](https://hatang.dev/docs/guides/sdks/python.md)), or the [REST API](https://hatang.dev/docs/reference/api.md).

## A1. Pass the purchase token when buying {#purchase-token}

`purchaseToken(userId)` is derived from your site and the user id you pass to `identify`, so it is the same on every device and in your backend (`hatang.chat.subscriptions.purchaseToken(siteId, userId)` in `@hatang/sdk`). Use the same user id you sign the person in with.

**iOS**

```swift
let token = HatangChat.purchaseToken(userId: currentUser.id)   // nil before configure
let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? [])
```

**Android**

```kotlin
val params = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productParams))
    .setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id))
    .build()
billingClient.launchBillingFlow(activity, params)
```

**Flutter**

```dart
// in_app_purchase: applicationUserName becomes StoreKit's appAccountToken on iOS
// and Google Play's obfuscatedAccountId on Android.
final param = PurchaseParam(
  productDetails: details,
  applicationUserName: HatangChat.instance.purchaseToken(currentUser.id),
);
await InAppPurchase.instance.buyNonConsumable(purchaseParam: param); // subscriptions too
```

**React Native**

```ts
import { requestPurchase } from "react-native-iap"; // or "expo-iap": same API

const token = Hatang.purchaseToken(currentUser.id);
await requestPurchase({
  type: "subs", // "in-app" for one-time products
  request: {
    apple: { sku, appAccountToken: token },
    google: { skus: [sku], obfuscatedAccountId: token /* , subscriptionOffers: [{ sku, offerToken }] */ },
  },
});
```

## A2. Report each purchase {#report}

Call `reportPurchase` whenever your app sees a completed transaction, not only right after the buy button:

- after a successful purchase,
- for transactions that arrive later (renewals, purchases approved by a parent, pending payments that complete): `Transaction.updates` on iOS, `PurchasesUpdatedListener` on Android, the purchase stream in `in_app_purchase` / `react-native-iap`,
- when the person restores purchases.

Pass the store's proof: the StoreKit 2 **signed transaction** (JWS) on iOS, the **purchase token** and product type (`subs` or `inapp`) on Android. Ha Tang verifies it with the store, records the subscription or one-time purchase on the person, acknowledges Google Play purchases, and leaves an internal note in their open conversation. Reporting the same transaction again updates the same record instead of adding a second one.

**iOS**

```swift
func handle(_ result: VerificationResult<Transaction>) async {
    guard case .verified(let tx) = result else { return }
    await HatangChat.reportPurchase(storeProductId: tx.productID,
                                    transactionId: String(tx.originalID),
                                    signedTransaction: result.jwsRepresentation)
    await tx.finish()
}

// After product.purchase(…): if case .success(let result) = purchaseResult { await handle(result) }
// At launch, for renewals and late transactions:
Task { for await result in Transaction.updates { await handle(result) } }
// Restore:
for await result in Transaction.currentEntitlements { await handle(result) }
```

**Android**

```kotlin
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
    purchases.orEmpty().filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }.forEach { p ->
        scope.launch {
            HatangChat.reportPurchase(
                storeProductId = p.products.first(),
                purchaseToken = p.purchaseToken,
                productType = "subs",          // "inapp" for one-time products
                // consumable = true,          // one-time products you consume yourself (consumeAsync)
            )
        }
    }
}
// Restore: queryPurchasesAsync(QueryPurchasesParams…) and report each purchase the same way.
```

Ha Tang acknowledges subscriptions and non-consumables for you. Consumables are yours to consume with `consumeAsync`.

**Flutter**

```dart
InAppPurchase.instance.purchaseStream.listen((purchases) async {
  for (final p in purchases) {
    if (p.status == PurchaseStatus.purchased || p.status == PurchaseStatus.restored) {
      final ios = Platform.isIOS;
      await HatangChat.instance.reportPurchase(
        platform: ios ? 'ios' : 'android',
        storeProductId: p.productID,
        transactionId: p.purchaseID,
        // iOS: the StoreKit 2 JWS (the default since in_app_purchase_storekit 0.4); Android: the purchase token.
        signedTransaction: ios ? p.verificationData.serverVerificationData : null,
        purchaseToken: ios ? null : p.verificationData.serverVerificationData,
        productType: ios ? null : 'subs', // 'inapp' for one-time products
      );
    }
    if (p.pendingCompletePurchase) await InAppPurchase.instance.completePurchase(p);
  }
});
// Restore: InAppPurchase.instance.restorePurchases() — restored purchases come through the same stream.
```

**React Native**

```ts
import { finishTransaction, getAvailablePurchases, purchaseUpdatedListener } from "react-native-iap"; // or "expo-iap"

purchaseUpdatedListener(async (purchase) => {
  await Hatang.reportPurchase({
    storeProductId: purchase.productId,
    transactionId: purchase.id,
    // purchaseToken is the StoreKit 2 signed transaction (JWS) on iOS and the Play purchase token on Android.
    signedTransaction: Platform.OS === "ios" ? purchase.purchaseToken : null,
    purchaseToken: Platform.OS === "android" ? purchase.purchaseToken : null,
    productType: "subs", // "inapp" for one-time products
  });
  await finishTransaction({ purchase, isConsumable: false });
});
// Restore: for (const p of await getAvailablePurchases()) report p the same way.
```

These names are for **react-native-iap 15 or later** and **expo-iap 4 or later**, which share one API (checked against their type definitions up to react-native-iap 17.3 and expo-iap 6.4): `request.apple` / `request.google`, `obfuscatedAccountId` for Android, and `purchase.purchaseToken` holding the JWS on iOS. Earlier versions name these differently (`request.ios` / `request.android`, `obfuscatedAccountIdAndroid`, or `requestSubscription` before version 14); upgrade before wiring this in.

`reportPurchase` returns `true` when the purchase was recorded and `false` when the store refused the proof, the app is not connected to that store, or the call failed. **Finish or acknowledge the transaction in your app either way**: Ha Tang is a record for support, not your entitlement system, and a purchase it missed still arrives through the store's notification.

A purchase reported before the person signs in is kept on the anonymous visitor and moves to them when you call `identify`.

## B. Send from your backend {#backend}

Your server tells Ha Tang about each change it already knows of, under the same user id you pass to `identify`:

1. **After a purchase, a renewal, an expiry or a cancellation of a plan**, call `chat.subscriptions.upsert(platform, id, …)` with its current state: `status` (`active`, `trial`, `canceled`, `expired`, `refunded`…), `current_period_end`, and the product (`store_product_id`, or `sku` on the web).
2. **After a one-time purchase** (credit pack, unlock, course), call `chat.purchases.upsert(platform, id, …)` with `kind`, `amount`, `currency` and `purchased_at`; on a refund, send the same id again with `status: "refunded"`.
3. **Once**, send the history: every plan and purchase you already have, so past customers show too.

Sending the same record again only updates it (an unchanged plan is not even logged again), so your server can resend everything about a person after each change.

**Node.js**

```ts
import { Hatang } from "@hatang/sdk"; // 0.6.0+

const hatang = new Hatang(process.env.HATANG_API_KEY);

// A plan sold on your website
await hatang.chat.subscriptions.upsert("web", subscription.id, {
  external_user_id: user.id, sku: "PRO-1M", status: "active", auto_renew: false,
  started_at: "2026-10-01T00:00:00Z", current_period_end: "2026-11-01T00:00:00Z", amount: 99000, currency: "VND",
});
// An App Store plan your server verified
await hatang.chat.subscriptions.upsert("ios", originalTransactionId, {
  external_user_id: user.id, store_product_id: "com.example.pro.monthly", status: "active", current_period_end: expiresAt,
});
// A one-time purchase; later the same id with status "refunded" on a refund
await hatang.chat.purchases.upsert("web", order.id, {
  external_user_id: user.id, store_product_id: "credits.300", sku: "XU-300", kind: "consumable",
  amount: 29000, currency: "VND", purchased_at: order.paidAt,
});
```

**Python**

```python
from hatang import Hatang  # pip install hatang

hatang = Hatang()  # reads HATANG_API_KEY

# A plan sold on your website
hatang.chat.subscriptions.upsert("web", subscription.id, {
    "external_user_id": str(user.id), "sku": "PRO-1M", "status": "active", "auto_renew": False,
    "started_at": "2026-10-01T00:00:00Z", "current_period_end": "2026-11-01T00:00:00Z", "amount": 99000, "currency": "VND",
})
# An App Store plan your server verified
hatang.chat.subscriptions.upsert("ios", original_transaction_id, {
    "external_user_id": str(user.id), "store_product_id": "com.example.pro.monthly", "status": "active",
    "current_period_end": expires_at,
})
# A one-time purchase; later the same id with status "refunded" on a refund
hatang.chat.purchases.upsert("web", order.id, {
    "external_user_id": str(user.id), "store_product_id": "credits.300", "sku": "XU-300", "kind": "consumable",
    "amount": 29000, "currency": "VND", "purchased_at": order.paid_at.isoformat(),
})
```

### Which id to send {#backend-only}

| What | Platform | Id |
|---|---|---|
| Plan sold on your website | `web` | Your own subscription id |
| App Store plan | `ios` | Apple `originalTransactionId` (the same across renewals) |
| Google Play plan | `android` | The purchase token |
| One-time purchase on your website | `web` | Your order id (one record per line of a cart, such as `order_981:0`) |
| One-time App Store purchase | `ios` | Apple `transactionId` |
| One-time Google Play purchase | `android` | The Play order id (`GPA.…`), or the purchase token |

The product is found by `product_id`, by its `sku` in your catalog, or (App Store, Google Play) by the store product id. On the web, `store_product_id` is your own code for what was sold; it shows when no catalog product matches.

If your app also uses path A, store purchases meet the same records: Ha Tang keys them by the same ids.

### Read and import {#backend-read}

- Read a customer's records: `chat.subscriptions.list({ external_user_id })`, `chat.purchases.list({ external_user_id })`, or `forCustomer(customerId)`.
- Path A only: import existing store subscribers once, after connecting the stores: `POST /chat/subscriptions/import` with the app id and up to 500 ids (Apple transaction ids, with the App Store Server API key; Google Play purchase tokens of subscriptions). Agents can also do this from the dashboard.

See [Support REST API](https://hatang.dev/docs/guides/support/rest-api.md) for every field.

## What your team and the assistant see {#what-they-see}

- The customer panel in the inbox lists their plans (App Store, Google Play or Web; status such as trial, active, grace period, canceled or refunded; renewal or end date) and one-time purchases. With the stores connected (path A), a **Refresh** button asks the store again (on iOS this needs the App Store Server API key).
- The AI assistant gets a line with the person's current plan, so it can answer "when does my plan renew?" or "I was charged twice" from facts.
- Sandbox and test purchases are marked **sandbox** and never mixed up with real ones.

## Test in the sandbox {#testing}

- **iOS**: buy with a Sandbox Apple Account (or a StoreKit configuration file in Xcode for UI only: local StoreKit transactions are not signed by Apple, so Ha Tang cannot verify them). The App Store Connect page for notifications has **Request a Test Notification** to check the URL.
- **Android**: add license testers in Play Console and install the app from an internal testing track. Test purchases renew every few minutes, which is a quick way to watch renewals arrive.
- Open the person's conversation in the inbox: the plan should appear on their profile within seconds of `reportPurchase`.
- **Path B**: send one plan and one purchase for a test user who has opened the chat, then check their profile. Use `environment: "sandbox"` for store sandbox purchases so they are marked as such.

## Troubleshooting {#troubleshooting}

| What you see | Likely cause |
|---|---|
| Nothing on the profile after a purchase | No proof passed (`signedTransaction` / `purchaseToken`), the app is not connected to that store, or `reportPurchase` was never called for that transaction. Only an internal note is written then |
| Renewals or refunds never arrive, or land on no one | The purchase was made without `purchaseToken(userId)`, or with a different user id than the one passed to `identify`. Import the subscription from the dashboard to attach it |
| `Bundle id … is not this app's` | The bundle id in App Store Connect differs from the one entered for the app in the dashboard |
| `Connect Google Play for this app first` | The package name and service account are missing for the app |
| Google refunds Android purchases after three days | Nothing acknowledged them: connect the service account, or acknowledge in the app |
| iOS purchases are never verified in testing | Local StoreKit testing in Xcode; use a Sandbox Apple Account |
| Path B: records exist but not on the customer | `external_user_id` differs from the id passed to `identify`, or the person has never opened the chat (they appear once they do) |
| Path B: the panel shows a code instead of the product name | No product with that SKU or store product id: set it on the product in the catalog |

Source: https://hatang.dev/docs/guides/support/in-app-purchases
