Skip to main content

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 connectedB. From your backend
Who checks the purchaseHa Tang, with Apple and GoogleYour server, as it does today
What you addpurchaseToken and reportPurchase in the app; the stores' notifications go to Ha Tangchat.subscriptions.upsert and chat.purchases.upsert from your server after each change
Renewals, expiries, refundsFrom the stores' notifications to Ha TangYour server sends them as it learns them
Web salesSent from your backend (as in B)Included
SuitsApps whose servers do not check store purchases yetApps 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
  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​

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). 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-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) or hatang 0.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.

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

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

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

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:

  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.

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,
});

Which id to send​

WhatPlatformId
Plan sold on your websitewebYour own subscription id
App Store planiosApple originalTransactionId (the same across renewals)
Google Play planandroidThe purchase token
One-time purchase on your websitewebYour order id (one record per line of a cart, such as order_981:0)
One-time App Store purchaseiosApple transactionId
One-time Google Play purchaseandroidThe 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 }), 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 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 seeLikely cause
Nothing on the profile after a purchaseNo 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 oneThe 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'sThe bundle id in App Store Connect differs from the one entered for the app in the dashboard
Connect Google Play for this app firstThe package name and service account are missing for the app
Google refunds Android purchases after three daysNothing acknowledged them: connect the service account, or acknowledge in the app
iOS purchases are never verified in testingLocal StoreKit testing in Xcode; use a Sandbox Apple Account
Path B: records exist but not on the customerexternal_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 nameNo product with that SKU or store product id: set it on the product in the catalog

View this page as Markdown