Skip to main content

Android Client-Side Offer Feed

Render the SDK's offers in your own UI, and hand selections back to the SDK's screens.

Added in 1.3.0

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.api returns 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​

  1. 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.
  2. Observe offerFeed and render its groups in your own UI. Each OfferGroup carries its title and offers.
  3. On a tap, wrap the tapped object in an OffersWallDestination — OfferDetails for an offer, Group for a whole group — and present OffersWall (or OffersWallFlow) with it as initialDestination.
  4. 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:

MemberTypePurpose
refreshOfferFeed()suspend fun (): OfferFeedResultFetches the feed now and reports how it went.
offerFeedStateFlow<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 withTimeout if your screen needs a tighter budget. With no user registered and no registration in progress, it returns NOT_REGISTERED immediately, without a network call.
  • It's main-thread safe. Network, decoding, and mapping all run off the calling thread.

What to know about offerFeed:

  • null and empty are different. null means "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 with collectAsState() / collectAsStateWithLifecycle() in Compose, or repeatOnLifecycle in Views.
Kotlin-first

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​

TypeFieldTypeMeaning
OfferFeedgroupsList<OfferGroup>The groups, in the order Actual's backend ranked them.
OfferGroupidIntStable id of the group.
titleStringDisplay title, e.g. "Featured".
offersList<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:

FieldTypeMeaning
idIntOffer id.
titleStringOffer title.
subtitleString?Short supporting line, if any.
rewardValueRewardPointThe full reward, already converted into your configured reward-currency units. Read the number with .value.
imageOfferSummaryImage?Offer artwork: url, plus the intrinsic width and height (0 when unknown) so you can reserve space before it loads.
merchantsList<Store>Stores the offer is valid at. Empty means every store.
endDateCalendarDate?Last day of the offer: year / month / day, no time zone. isoString gives YYYY-MM-DD.
clipRequiredBooleanWhether the shopper must clip the offer before buying.
clippedBooleanWhether the shopper has clipped it.
tagsList<String>Display tags.

The variants add their qualifying condition:

VariantExtra fieldsCard copy it implies
OfferSummary.ProductCouponminQuantity: Int — products to buy"Buy 2"
OfferSummary.MerchantPromominimumSpend: 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
}
Keep an else branch

Future 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:

DestinationBuilt fromOpens
OffersWallDestination.OfferDetails(summary)A tapped OfferSummaryThe 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 onScanReceipt behaves 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.

OfferFeedErrorMeaningSuggested handling
NOT_REGISTEREDNo 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_FAILEDThe 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.
DISABLEDThe 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 Success with empty groups.
  • DISABLED is not "empty". The request succeeded and the offer wall keeps working, but no feed is exposed — offerFeed observes 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:

EventWhat happens to offerFeed
The shopper clips an offer inside the SDKRe-emits with that offer's clipped set to true.
You reassign ActivationClient.instance.rewardCurrencyRe-emits with rewardValue converted into the new currency.
The offer wall reloads its catalogRe-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​

PitfallWhy it happensWhat 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 loopIt 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 sessionsThe 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 OfferFeedErrorThe current variants are all known.Keep an else branch; both can grow in later releases.
Calling the API before the SDK is startedThe feed screen can be composed early.ActivationClient.instance throws until ActivationClient.initialize(privacy) has run — see SDK Initialization.
Assuming an offer applies at every storemerchants is easy to skip.Empty merchants means every store; otherwise list the stores, and for merchant promos also respect excludedMerchants.