# Offers

> Hand the right discount or comp to the right user in one tap, and prove it made money.

Source: https://conciergesdk.com/docs/offers/

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.

## The problem the engine solves

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.

## How it works

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](https://conciergesdk.com/docs/mcp.md) can send cards, list eligible offers and grant comps too.

## In the app

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:

```swift
Concierge.onPaywallCard { paywallId in paywallPresenter.show(paywallId) }
```

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

```swift
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
    }
}
```

```kotlin
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](https://conciergesdk.com/docs/react-native.md#cards-and-events) and [Flutter](https://conciergesdk.com/docs/flutter.md#cards-and-events).

## Link purchases to customers

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](https://conciergesdk.com/docs/identity.md) 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](https://conciergesdk.com/docs/ios.md#link-purchases-to-customers), [Android](https://conciergesdk.com/docs/android.md#link-purchases-to-customers), [React Native](https://conciergesdk.com/docs/react-native.md#link-purchases-to-customers), [Flutter](https://conciergesdk.com/docs/flutter.md#link-purchases-to-customers).

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.

## Not built yet

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