Skip to content

Offers

Markdown

When someone writes “too expensive” or “I’m cancelling”, your team (or the AI) sends an offer card from the inbox. The engine picks the store mechanism that works for that user’s platform and subscription state, the SDK runs it, and the store webhook proves whether it made money.

There’s no single “discount” button. Each store has four or five mechanisms, and which one works depends on the platform and the user’s subscription state:

User state iOS Android Everywhere
Never subscribed Offer code, or a cheaper alternate product Developer determined offer, or a promo code Comp entitlement
Subscriber, price complaint Promotional offer (server signed) Offer on another base plan Comp entitlement
Subscriber, “you broke it” Extend renewal date (1 to 90 days) Defer the renewal Comp entitlement
Lapsed Win-back offer Developer determined offer Comp entitlement
Wants a refund beginRefundRequest sheet Refund through the Play API

Nobody should have to learn this table. That’s the product.

  1. Offer. You define it once in the dashboard under Offers: card copy (“40% off Yearly”, “For the first year”), an optional price row, and an ordered fallback chain of mechanisms for iOS and for Android.
  2. Policy. Who can send it (human only, AI suggests, or AI sends), which subscription states it targets, a per user limit, a cooldown, monthly and total caps, an org wide monthly cap, and whether the user must be verified.
  3. Eligibility. Concierge knows the user’s subscription state from RevenueCat, App Store Server Notifications v2 or Google RTDN, so the inbox’s Offer button lists the offers this user can get first, with the reasons the others are blocked.
  4. Resolver. When the user taps, the server walks the chain for their platform and picks the first step they can use: a product, an Apple promotional offer (signed on the server), a win-back offer, a one time offer code from your pool, a Play developer determined offer, a comp, or your paywall.
  5. Fulfillment. Each card is single use and expires (48 hours by default). Comps are granted through RevenueCat by the worker, and a confirmation message follows in the chat.
  6. Proof. The purchase carries the card’s accountToken, so the store webhook tells us it converted. A purchase is proven when the store hands the token back, and influenced when the user bought within the attribution window (7 days by default) after the card. Renewals keep the attribution of the first purchase. The Revenue page and each conversation show “this conversation made $X”.

Built today: offers with policies and fallback chains, eligibility, the resolver, review, paywall and offer cards with impressions and taps, offer code pools, Apple promotional offer signing, RevenueCat comps, and attribution from RevenueCat, the App Store and Google Play. The MCP server can send cards, list eligible offers and grant comps too.

The SDK renders the card in the chat and redeems it on tap. Review cards open your store review page. Paywall cards call your paywall handler:

Concierge.onPaywallCard { paywallId in paywallPresenter.show(paywallId) }
Concierge.onPaywallCard { paywallId -> showPaywall(paywallId) }

Offer cards are redeemed on the server first. Set onOfferCard to run the purchase yourself (RevenueCat, Superwall, Adapty, your own billing). It receives the redemption: { cardId, offerId, mechanism, accountToken, expiresAt }, where mechanism is what the resolver picked.

Concierge.onOfferCard { redemption in
switch redemption.mechanism {
case .product(let productId): buy(productId, appAccountToken: redemption.appAccountToken)
case .paywall(let paywallId): paywallPresenter.show(paywallId)
default: break
}
}
Concierge.onOfferCard { redemption ->
when (val m = redemption.mechanism) {
is ConciergeOfferMechanism.AndroidOffer -> launchBilling(m.productId, m.basePlanId, m.offerId, redemption.accountToken)
is ConciergeOfferMechanism.Paywall -> showPaywall(m.paywallId)
else -> Unit
}
}

Pass accountToken to the store so the sale is proven: appAccountToken with StoreKit, setObfuscatedAccountId with Play Billing, or the RevenueCat subscriber attribute concierge_card.

Without onOfferCard, the SDK handles it: on iOS, products, promotional and win-back offers buy through StoreKit 2; offer codes open the redeem page; paywalls call onPaywallCard; comps show “Unlocking…” until the confirmation arrives. The Android SDK has no billing dependency, so store offers there need onOfferCard. React Native and Flutter use the same handlers, see React Native and Expo and Flutter.

Money protection (the refund desk’s consent check and its answers to Apple, purchase receipts, renewal heads ups and win-back history) needs to know which customer made each store purchase. Concierge only trusts what the store signs, never what the app reports with setContext, because any device can put someone else’s transaction id in its own context. A purchase belongs to a customer when one of these is true:

  1. The purchase carries the customer’s account token: StoreKit appAccountToken on iOS, Play Billing setObfuscatedAccountId on Android (it comes back as obfuscatedExternalAccountId).
  2. RevenueCat’s app user id is the verified user id, the same id your server signs into the identity token (Purchases.logIn(userId)).
  3. The purchase came from an offer card and carries that card’s accountToken (see above). Keep passing it for card purchases: it takes priority for that purchase and still links it to the customer.

The account token comes from the verified user id, the sub of the identity token you pass to identify. Unverified identifies never match.

  • If the user id is a UUID, the token is that UUID (case doesn’t matter).
  • Otherwise the token is the name based UUID (version 3) of the user id: exactly Java’s UUID.nameUUIDFromBytes(userId.toByteArray(Charsets.UTF_8)), which is the MD5 of the UTF-8 bytes with the version and variant bits set.

Use the same token on both platforms. On Play, a lowercase raw user id (up to 64 characters) as the obfuscated account id also matches, but one token everywhere is easier to keep straight. Snippets: iOS, Android, React Native, Flutter.

The Money page’s Settings tab warns “Purchases can’t be matched to customers yet” when no store event in the last 90 days carried an account token or a RevenueCat app user id. Until that clears, these features can’t tie a purchase to anyone.

  • AI auto sending: the autonomy setting is enforced, but the AI doesn’t send offers on its own yet.
  • Extend renewal and defer, and refunds from the inbox.
  • Store catalog import and a create offer wizard from App Store Connect and Play Console.
  • Money budgets: caps are counts today.
  • Currency conversion for non USD App Store and Play events (RevenueCat events are converted).
  • Billing rescue: a failed renewal triggers a push and an in-app link to fix the payment.
  • Cancellation check-in: when someone turns off auto-renew, a gentle “what went wrong?” and an offer if your policy allows. Cancelling is never blocked.
  • Reviews gained: review cards counted by reviews, not taps, and sent after a good resolution. The native review prompt is never filtered by sentiment, which keeps you inside Apple’s rules.