Skip to content
The mobile SDKs are available on request. Email support@grona.ai to get access.

Offers and grants

This page connects Grona offers, such as 10% off this order at checkout, to your backend. An offer carries money, so the app alone never decides it. Grona signs a grant for each shopper who may see the offer, your backend fetches it, the SDK renders the offer only with a matching grant, and your order backend verifies the same grant before it applies anything.

  1. The shopper signs in and your app calls Grona.identify.
  2. The SDK calls your GronaGrantSource on a background thread.
  3. Your grant source calls your backend, inside the shopper’s authenticated session.
  4. Your backend calls Grona with its secret key and returns the grants it receives.
  5. The SDK verifies each grant with the grant keys in your options and renders offers that match.
  6. At checkout your app reads Grona.activeTreatments() and sends each token to your order API.
  7. Your order backend verifies each token and applies the treatment from the verified claims.

For the server side of steps 4 and 7, see Grants and order verification and the Mobile grants API.

GronaGrantSource has one method. It returns the grants as compact JWS strings, or null on failure.

AcmeGrantSource.kt
package com.example.acmemarket
import ai.grona.sdk.GronaGrantSource
import java.io.IOException
class AcmeGrantSource(private val api: AcmeApi) : GronaGrantSource {
// Called on a Grona background thread; may block for up to 10 seconds
override fun fetchGrants(customerId: String): List<String>? =
try {
// Your endpoint, for example GET https://shop.example.com/api/grona/grants,
// authenticated by the shopper's session
api.fetchGronaGrants()
} catch (e: IOException) {
null // a failure: the SDK retries with backoff
}
}
  • Your backend picks the customer. Take the customer from your own authenticated session, not from a parameter the app sends. The customerId argument tells you which shopper the SDK expects. Grants for another customer fail verification on the device.
  • Threading. The SDK calls the source on its background threads, sometimes at the same time as your own code. Keep it thread-safe and do not touch views.
  • Timeouts. Past 10 seconds the SDK stops waiting, counts a failure and retries with backoff. It calls the source again only once the earlier call has returned.
  • Return values. Return null on failure. Return an empty list when the shopper has no grants.

Add the grant source and the grant signing keys that Grona provides for your workspace. Add the current and the next key, so a key rotation needs no app release.

val options = GronaOptions.Builder("acme-market")
.configUrl("https://api.grona.ai/v1/mobile/workspaces/acme-market/config")
.bundledConfigAsset("grona/config.json")
.configKey("acme-market-2026-10", "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...")
.grantKey("acme-market-grants-2026-10", "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...")
.grantSource(AcmeGrantSource(api))
.eventsUrl("https://api.grona.ai/v1/mobile/events")
.build()

For rotation, see Rotate signing keys.

  • On identify, and again shortly before the nearest grant expires.
  • Grants live in memory only, at most 50 at a time.
  • They are cleared when the customer changes, on identify(null) and on reset().

An offer renders only when a verified, unexpired grant matches its campaign, variant, treatment type and value. Without one the shopper is not treated: the campaign yields to the next one in the placement, as a control arm does. Offers use customer-unit assignment, so a shopper who is not identified never sees one.

Right after sign-in the first grant fetch may still be running. The offer then appears when the grant arrives, replacing whatever the placement showed.

At checkout, read the treatments the shopper can currently see and send their tokens to your order API.

import ai.grona.sdk.Grona
import ai.grona.sdk.GronaEvent
import ai.grona.sdk.GronaScreen
fun placeOrder(cart: AcmeCart) {
val shown = Grona.activeTreatments()
val claims = shown.mapNotNull { t ->
t.token?.let { token -> TreatmentClaim(t.campaignId, t.variantId, t.type, token) }
}
orderApi.submit(cart.toOrderDraft(), claims) { result -> // delivered on the main thread
val applied = shown.filter { t -> result.applied.any { it.campaignId == t.campaignId && it.variantId == t.variantId } }
Grona.track(GronaEvent.purchase(result.orderId, result.totalMinor, cart.productIds(), applied))
// On the confirmation screen
confirmationSlot.screen = GronaScreen.orderConfirmation(applied.map { it.type })
}
}

TreatmentClaim and orderApi are your own types. Send the token and the ids, never the value: your backend reads the value from the verified token.

activeTreatments() returns offers in live slots: attached, shown and in a visible window. Offers shown only because of a preview pin never appear in it.

GronaTreatment property Meaning
campaignId, variantId The campaign and variant
placement Where it was shown
type The treatment type
valueMilli Value in thousandths, so 10000 is 10 (10% off with percent_off)
token The Grona-signed grant your backend must verify. null in demo mode
isDemo true in demo mode

Every field except token is display data. Your order backend must verify the token, enforce single use on the grant’s jti claim in its own records (never on the token string, because one grant can have more than one valid signature), and apply only what the verified claims say. A missing or invalid token means no treatment, and the order still succeeds. There is no Grona redeem endpoint.

demoMode(true) lets offers render without a grant, so a demo build can run without a backend. It takes effect only when the signed configuration also sets demo mode. With only one switch on, the SDK behaves as in production and logs a warning. In demo mode treatments carry no token, isDemo is true, and every event is marked as demo and excluded from analysis.