Android Client-Side Offer Feed
Render the SDK's offers in your own UI, and hand selections back to the SDK's screens.
The Client-Side Offer Feed is new in Activation Android SDK 1.3.0. See the 1.3.1 release notes.
Overview
The offer wall (OffersWall / OffersWallFlow) is a complete, SDK-rendered experience. The Client-Side Offer Feed is the alternative for apps that want to present offers themselves — a carousel on your home screen, a section in your own rewards tab — while still using the SDK for everything that happens after a tap.
It has two halves:
- Data.
ActivationClient.instance.apireturns the same curated offers the offer wall shows, as plain Kotlin models: groups, each with its offers, in the order Actual's backend ranked them. Reward values are already converted into your configured reward currency. - Entry points. When the shopper taps one of your cards, you hand the tapped model back to the SDK as an
OffersWallDestination, and the offer wall opens directly on that offer's details or that group.
Your app owns the layout. The SDK owns the data, the offer details, clipping, and scanning.
How it works
- Load the feed with
refreshOfferFeed()— typically when the screen that shows it appears. Concurrent calls share one request, so this is safe to call eagerly. - Observe
offerFeedand render itsgroupsin your own UI. EachOfferGroupcarries itstitleandoffers. - On a tap, wrap the tapped object in an
OffersWallDestination—OfferDetailsfor an offer,Groupfor a whole group — and presentOffersWall(orOffersWallFlow) with it asinitialDestination. - Refresh when the feed may be stale (for example when your screen returns to the foreground). Don't cache feed objects long-term: an offer can expire or be withdrawn, and a destination built from a stale offer opens the wall without its details.
Loading and observing the feed
Both members live on ActivationClient.instance.api:
| Member | Type | Purpose |
|---|---|---|
refreshOfferFeed() | suspend fun (): OfferFeedResult | Fetches the feed now and reports how it went. |
offerFeed | StateFlow<OfferFeed?> | The current feed. null until the first successful load. |
import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.OfferFeedError
import com.actualplatform.activation.OfferFeedResult
val api = ActivationClient.instance.api
// Fetch — you decide when. The SDK never loads the feed on its own initiative.
when (val result = api.refreshOfferFeed()) {
is OfferFeedResult.Success -> Unit // offerFeed has already emitted the new feed
is OfferFeedResult.Failure -> when (result.error) {
OfferFeedError.NOT_REGISTERED -> hideFeed() // no registered user yet
OfferFeedError.REQUEST_FAILED -> hideFeed() // try again on your next refresh
OfferFeedError.DISABLED -> hideFeed() // the feed is turned off for your app
else -> hideFeed() // categories may be added in later releases
}
}
What to know about refreshOfferFeed():
- It always fetches. Every call (that isn't joined to one already in flight) asks Actual's backend for the current feed. Overlapping calls — including the offer wall's own reload at the same moment — share a single request.
- It waits for registration. A call made while the SDK is still registering the user waits for registration to finish rather than failing. That wait is bounded by the SDK's HTTP timeouts (roughly two minutes with the defaults). The call is cancellable, so wrap it in
withTimeoutif your screen needs a tighter budget. With no user registered and no registration in progress, it returnsNOT_REGISTEREDimmediately, without a network call. - It's main-thread safe. Network, decoding, and mapping all run off the calling thread.
What to know about offerFeed:
nulland empty are different.nullmeans "not loaded yet";OfferFeed(groups = emptyList())means "loaded, and there's nothing to show right now". Render a placeholder for the first and hide or collapse your surface for the second.- It reflects every load, not just yours. It updates after your
refreshOfferFeed()calls and after the offer wall's own reloads, and it keeps its last value across a failed refresh. - It is a
StateFlow, so collect it withcollectAsState()/collectAsStateWithLifecycle()in Compose, orrepeatOnLifecyclein Views.
refreshOfferFeed() is a suspend function and offerFeed is a StateFlow. Call them from Kotlin; from a Java codebase, expose them through a small Kotlin wrapper.
Data model
The feed models are in com.actualplatform.activation.models.
OfferFeed and OfferGroup
| Type | Field | Type | Meaning |
|---|---|---|---|
OfferFeed | groups | List<OfferGroup> | The groups, in the order Actual's backend ranked them. |
OfferGroup | id | Int | Stable id of the group. |
title | String | Display title, e.g. "Featured". | |
offers | List<OfferSummary> | The group's offers, in ranked order. |
OfferSummary
OfferSummary is a sealed type with one variant per kind of offer. Every variant shares these fields:
| Field | Type | Meaning |
|---|---|---|
id | Int | Offer id. |
title | String | Offer title. |
subtitle | String? | Short supporting line, if any. |
rewardValue | RewardPoint | The full reward, already converted into your configured reward-currency units. Read the number with .value. |
image | OfferSummaryImage? | Offer artwork: url, plus the intrinsic width and height (0 when unknown) so you can reserve space before it loads. |
merchants | List<Store> | Stores the offer is valid at. Empty means every store. |
endDate | CalendarDate? | Last day of the offer: year / month / day, no time zone. isoString gives YYYY-MM-DD. |
clipRequired | Boolean | Whether the shopper must clip the offer before buying. |
clipped | Boolean | Whether the shopper has clipped it. |
tags | List<String> | Display tags. |
The variants add their qualifying condition:
| Variant | Extra fields | Card copy it implies |
|---|---|---|
OfferSummary.ProductCoupon | minQuantity: Int — products to buy | "Buy 2" |
OfferSummary.MerchantPromo | minimumSpend: Double and currency: String? — the spend threshold; redemptionLimit: RedemptionLimit? — how many times it can be redeemed (count) over a rolling window of periodDays (null means the cap applies for the offer's lifetime); excludedMerchants: List<Store> — stores it is explicitly not valid at; clipExpirationDate: Instant? — when a clip expires | "Spend $25" |
Merchant promos are spend-based offers that qualify on the total spent at a store rather than on specific products — see Merchant Promos.
when (offer) {
is OfferSummary.ProductCoupon -> tag = "Buy ${offer.minQuantity}"
is OfferSummary.MerchantPromo -> tag = "Spend ${formatMoney(offer.minimumSpend, offer.currency)}"
else -> tag = null // new offer kinds may be added; keep an else branch
}
else branchFuture releases may add OfferSummary variants. Always handle the unknown case — an exhaustive when without else will stop compiling when a new variant ships.
The feed only includes offers it can describe truthfully: an offer whose type, spend goal, or store restrictions can't be represented is left out rather than shown with misleading copy.
Opening SDK screens
When the shopper taps one of your cards, hand the model back to the SDK through the initialDestination parameter on OffersWall or OffersWallFlow:
| Destination | Built from | Opens |
|---|---|---|
OffersWallDestination.OfferDetails(summary) | A tapped OfferSummary | The offer wall, with that offer's detail sheet on top. |
OffersWallDestination.Group(group) | A tapped OfferGroup (e.g. your "See all") | The offer wall, at that group. |
import com.actualplatform.activation.OffersWall
import com.actualplatform.activation.OffersWallDestination
var destination by remember { mutableStateOf<OffersWallDestination?>(null) }
// In your own card:
onClick = { destination = OffersWallDestination.OfferDetails(offer) }
// In your own "See all":
onClick = { destination = OffersWallDestination.Group(group) }
destination?.let {
OffersWall(
initialDestination = it,
onScanReceipt = { scanAndAwait() },
onDismiss = { destination = null },
)
}
How destinations behave:
- They route through the offer wall. The wall loads first and the requested surface opens on top of it. Closing the detail sheet or backing out of the group leaves the shopper in the wall; only dismissing the wall itself (
onDismiss) returns them to your app. - They are applied once per presentation. A destination is not re-applied on recomposition, after a scan round-trip, or after a configuration change, so it never overrides where the shopper has navigated since. Presenting the wall again applies whatever destination you pass that time.
- A destination that no longer resolves is not an error. A group the wall can't find opens the wall at its root. An offer that is no longer available opens the wall at its root with a short "This offer is currently unavailable." message. Neither throws nor reaches
onException. - Scanning works as usual. A wall opened from a destination is the same wall: the shopper can browse, clip, and scan from it, and
onScanReceiptbehaves exactly as it does without a destination.
Errors
refreshOfferFeed() returns either OfferFeedResult.Success(feed) or OfferFeedResult.Failure(error). OfferFeedResult will only ever have these two cases, so you can match it exhaustively; OfferFeedError may gain categories, so keep an else branch on it.
OfferFeedError | Meaning | Suggested handling |
|---|---|---|
NOT_REGISTERED | No registered user, and no registration in progress. | Check that the SDK is initialized and a user identity (hashedEmail / hashedPhone) is set. Gate your feed on registrationState. |
REQUEST_FAILED | The request didn't produce a usable response. | Hide or keep your current UI and try again on your next natural refresh. The cause is written to the SDK log. |
DISABLED | The feed is turned off for your app by Actual. | Hide your feed surface and don't retry in a loop. The offer wall itself is unaffected. |
Two non-errors worth knowing:
- An empty feed is a success. A user with no offers right now gets
Successwith emptygroups. DISABLEDis not "empty". The request succeeded and the offer wall keeps working, but no feed is exposed —offerFeedobserves an empty feed, so a surface that only observes the flow shows nothing. "Turned off" and "no offers right now" stay distinguishable through the result.
Live updates
offerFeed re-emits on its own in these cases — no extra fetch needed:
| Event | What happens to offerFeed |
|---|---|
| The shopper clips an offer inside the SDK | Re-emits with that offer's clipped set to true. |
You reassign ActivationClient.instance.rewardCurrency | Re-emits with rewardValue converted into the new currency. |
| The offer wall reloads its catalog | Re-emits with the fresh feed. |
The user's identity changes (e.g. a different hashedEmail or clientUserId) | Resets to null immediately, so one user's offers are never shown to the next. Call refreshOfferFeed() for the new user. |
Full example
import androidx.compose.runtime.*
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.actualplatform.activation.ActivationClient
import com.actualplatform.activation.OfferFeedResult
import com.actualplatform.activation.OffersWall
import com.actualplatform.activation.OffersWallDestination
@Composable
fun HomeOffersSection() {
val api = ActivationClient.instance.api
// Observe — null until the first successful load, then live across every refresh,
// including the offer wall's own reloads.
val feed by api.offerFeed.collectAsStateWithLifecycle()
var visible by remember { mutableStateOf(true) }
// Retrieve — your screen decides when.
LaunchedEffect(Unit) {
visible = api.refreshOfferFeed() is OfferFeedResult.Success
}
// Render with your own UI. The SDK only supplies the data.
var destination by remember { mutableStateOf<OffersWallDestination?>(null) }
if (visible) {
when {
feed == null -> OffersPlaceholder()
feed!!.groups.isEmpty() -> Unit // loaded, nothing to show right now
else -> feed!!.groups.forEach { group ->
MyOfferCarousel(
title = group.title,
offers = group.offers,
onOfferTap = { offer -> destination = OffersWallDestination.OfferDetails(offer) },
onSeeAllTap = { destination = OffersWallDestination.Group(group) },
)
}
}
}
// Open the SDK only on an explicit tap.
destination?.let {
OffersWall(
initialDestination = it,
onScanReceipt = { scanAndAwait() },
onDismiss = { destination = null },
)
}
}
scanAndAwait() stands for your camera launch, suspended until the scan finishes — see Option A: OffersWall.
Pitfalls
| Pitfall | Why it happens | What to do |
|---|---|---|
Treating null as "no offers" | Both render as nothing at first glance. | null is "not loaded yet"; show a placeholder. Only an empty groups list means "no offers". |
Retrying DISABLED in a loop | It arrives as a Failure, so it looks like a transient error. | It's a per-app switch, not a failure of the request. Hide the surface. |
| Caching feed objects across sessions | The models look like plain data. | Offers expire and change. Refresh the feed and rebuild destinations from the latest offerFeed value. |
Exhaustive when over OfferSummary or OfferFeedError | The current variants are all known. | Keep an else branch; both can grow in later releases. |
| Calling the API before the SDK is started | The feed screen can be composed early. | ActivationClient.instance throws until ActivationClient.initialize(privacy) has run — see SDK Initialization. |
| Assuming an offer applies at every store | merchants is easy to skip. | Empty merchants means every store; otherwise list the stores, and for merchant promos also respect excludedMerchants. |