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
| 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
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
- 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. - After a purchase the app calls
reportPurchasewith 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. - 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
For both paths:
- The chat in your app and website, signing people in with
identifyand the user id your backend knows them by (see Mobile apps). 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.
For path A:
- The chat SDK with the purchase calls:
@hatang/chat-react-native0.4.0+,hatang_chat0.6.0+,dev.hatang:chat0.4.0+ orHatangChat0.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.
- 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,
The same help article walks through both stores step by step.
For path B:
- An API key with the
crm:writescope, kept on your server. - A server SDK:
@hatang/sdk0.6.0+ (Node.js) orhatang0.1.0+ (Python), or the REST API.
A1. Pass the purchase token when buying
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
- Android
- Flutter
- React Native
let token = HatangChat.purchaseToken(userId: currentUser.id) // nil before configure
let result = try await product.purchase(options: token.map { [.appAccountToken($0)] } ?? [])
val params = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productParams))
.setObfuscatedAccountId(HatangChat.purchaseToken(currentUser.id))
.build()
billingClient.launchBillingFlow(activity, params)
// 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
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
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.updateson iOS,PurchasesUpdatedListeneron Android, the purchase stream inin_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
- Android
- Flutter
- React Native
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) }
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.
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.
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
Your server tells Ha Tang about each change it already knows of, under the same user id you pass to identify:
- 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, orskuon the web). - After a one-time purchase (credit pack, unlock, course), call
chat.purchases.upsert(platform, id, …)withkind,amount,currencyandpurchased_at; on a refund, send the same id again withstatus: "refunded". - 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
- Python
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,
});
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
| 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
- Read a customer's records:
chat.subscriptions.list({ external_user_id }),chat.purchases.list({ external_user_id }), orforCustomer(customerId). - Path A only: import existing store subscribers once, after connecting the stores:
POST /chat/subscriptions/importwith 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 for every field.
What your team and the assistant 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
- 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
| 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 |