Coupons

Create coupon codes in the dashboard and let users redeem them in your app. The App Store and Google Play apply the discount; CashSDK records who used each code.

A coupon is a code you create in CashSDK, such as SPRING-25. It gives a percentage off, an amount off, or a free period on the subscription plans you choose. The user types the code in your app, and the store applies the discount:

PlatformWhat CashSDK creates for each planHow the user redeems it
iOS and macOSAn App Store offer code with a custom codeThe SDK opens the App Store's offer code page with the code filled in
AndroidA Google Play offer on the plan's base planThe SDK buys the offer through Play Billing, like any purchase

On the Mac, coupons work for universal purchase apps, whose products are App Store products. Coupons for products in a separate Mac App Store record are not supported yet. See macOS / Catalyst.

The discount is always charged by the store, never by unlocking access in the app. That keeps coupons inside App Store and Google Play payment rules.

Create a coupon#

1
Connect the stores

CashSDK creates the store objects with your connected App Store Connect and Google Play credentials, so both must be connected for the app. Creating a coupon needs the admin role.

2
Create it in the dashboard

Dashboard, your app, Coupons, then create a coupon. Set the code, the discount, how many periods it lasts, the plans it covers, and optionally a start date, an expiry, a total cap and whether it is for new customers only. The form shows each store's limits before you save.

3
Wait for the store offers

Each plan gets its own store offer. The coupon page shows each one's state. An App Store code can take up to an hour before Apple accepts it; until then the SDK answers notReady. Your app must be live on the App Store for iOS codes to redeem.

Store limits worth knowing before you create an iOS coupon: at most 25,000 uses, an expiry at most 6 months out, one use per Apple Account, and at most 10 active offer codes per plan at Apple. Apple also creates at least 500 codes per plan, so CashSDK enforces a smaller cap itself and switches the codes off when it is reached.

The same operations exist over the public API (/v1/coupons, with a live secret key) and in the MCP server's tool list.

Redeem a coupon in your app#

Validate the code first to show the user what they get, then redeem it for one plan.

swift
let check = try await CashSDK.shared.validateCoupon(codeField.text ?? "")
guard check.valid, let coupon = check.coupon, let plan = check.eligibleProductIds.first else {
    showError(check.reason.map { CouponError.rejected($0).localizedDescription } ?? "")
    return
}
showOffer(coupon)   // kind, percentOff, formattedAmountOff(), duration, periodCount

let redemption = try await CashSDK.shared.redeemCoupon(coupon.code, productId: plan)
// The App Store is now open. When the user comes back:
switch try await CashSDK.shared.awaitCouponCompletion(redemptionId: redemption.redemptionId) {
case .completed(let entitlements): unlock(entitlements)
case .timedOut: break   // a later purchase still arrives on entitlementUpdates
}
kotlin
lifecycleScope.launch {
    val check = CashSDK.shared.validateCoupon(codeField.text.toString())
    if (!check.valid) return@launch showError(check.reason?.message ?: "This code cannot be used.")
    val productId = check.eligibleProductIds.first()
    // Several base plans under one coupon: let the user choose, then pass the choice.
    val basePlanId = check.basePlanIds(productId).let { if (it.size > 1) choosePlan(it) else null }
    try {
        val result = CashSDK.shared.redeemCoupon(activity, check.coupon!!.code, productId, basePlanId)
        if (result.alreadyOwned) showAlreadySubscribed() else unlock(result)
    } catch (e: CouponException.OfferNotAvailableYet) {
        showError(e.message)   // a new Play offer can take a while to reach devices
    } catch (e: CouponException.Rejected) {
        showError(e.reason.message)
    }
}
  • A refused code is a result with valid == false and a reason (notFound, expired, exhausted, alreadyRedeemed, notEligible, notReady and others), not an error.
  • redeemCoupon reserves one use first. The same user and coupon always get the same reservation, so a retry or a second tap is safe.
  • iOS: the purchase arrives through StoreKit and is verified like any purchase, even if the app was killed while the user was in the App Store. awaitCouponCompletion works after a relaunch.
  • Android: redeemCoupon returns the same Entitlements as purchase. When the Google account already owns the plan, nothing is charged, the coupon is not used, and alreadyOwned is true.
  • Coupon offers never show up as trials. The Android SDK leaves them out of products(), the paywall and every purchase call; only redeemCoupon can buy one.

The full API is in each SDK's README: iOS and Android.

Track redemptions#

The coupon page lists every redemption: who, which platform and plan, the price paid, the status and the exact time, with a CSV export. Sandbox and TestFlight purchases are labelled and never count toward a cap or a user's allowance.

Subscribe to the coupon.redeemed webhook to react in real time. It is sent once per purchase chain, including for an iOS code typed straight into the App Store.