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.
How it fits together
Section titled “How it fits together”- The shopper signs in and your app calls
Grona.identify. - The SDK calls your
GronaGrantSourceoff the main actor. - Your grant source calls your backend, inside the shopper’s authenticated session.
- Your backend calls Grona’s grants endpoint,
POST https://api.grona.ai/v1/mobile/grants, with its secret key, and returns the grants it receives. - The SDK verifies each grant with the grant keys in your options and renders offers that match.
- At checkout your app reads
Grona.activeTreatments()and sends each token to your order API. - Your order backend verifies each token, enforces single use on the grant’s
jticlaim in its own records (never on the token string, because one grant can have more than one valid signature), 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.
Implement the grant source
Section titled “Implement the grant source”GronaGrantSource has one async method. It returns the grants as compact JWS strings, or throws on failure.
import Grona
struct AcmeGrantSource: GronaGrantSource { let api: AcmeAPI
// Called off the main actor. After 10 seconds the SDK stops waiting and cancels the task. func fetchGrants(customerID: String) async throws -> [String] { // Your endpoint, for example GET https://shop.example.com/api/grona/grants, // authenticated by the shopper's session try await api.fetchGronaGrants() }}- Your backend picks the customer. Take the customer from your own authenticated session, not from a parameter the app sends. The
customerIDargument tells you which shopper the SDK expects. Grants for another customer fail verification on the device. - Concurrency. The protocol requires
Sendable. The SDK calls the source off the main actor, sometimes while your own code runs, so keep it thread-safe and away from your views. - Timeouts. After 10 seconds the SDK stops waiting, cancels the task, counts a failure and retries with backoff. Check for cancellation, or use APIs that do, such as
URLSession’s async methods. The SDK calls the source again only once the earlier call has returned. - Return values. Throw on failure; the SDK retries with backoff. Return an empty array when the shopper has no grants.
Configure the keys and the source
Section titled “Configure the keys and the source”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.
let options = try GronaOptions( workspaceKey: "acme-market", configURL: URL(string: "https://api.grona.ai/v1/mobile/workspaces/acme-market/config"), bundledConfig: Bundle.main.url(forResource: "grona-config", withExtension: "json"), configKeys: ["acme-market-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."], grantKeys: ["acme-market-grants-2026-10": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."], eventsURL: URL(string: "https://api.grona.ai/v1/mobile/events"), grantSource: AcmeGrantSource(api: api))For rotation, see Rotate signing keys.
When grants are fetched
Section titled “When grants are fetched”- 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(nil)and onreset().
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.
Send treatments with the order
Section titled “Send treatments with the order”At checkout, read the treatments the shopper can currently see and send their tokens to your order API.
import Grona
@MainActorfunc placeOrder(cart: AcmeCart, orderAPI: AcmeOrderAPI, confirmationSlot: GronaSlotView) async throws { let shown = Grona.activeTreatments() let claims = shown.compactMap { treatment in treatment.token.map { token in TreatmentClaim(campaignID: treatment.campaignID, variantID: treatment.variantID, type: treatment.type, token: token) } } let result = try await orderAPI.submit(cart.orderDraft(), claims: claims) let applied = shown.filter { treatment in result.applied.contains { $0.campaignID == treatment.campaignID && $0.variantID == treatment.variantID } } Grona.track(.purchase(orderID: result.orderID, totalMinor: result.totalMinor, productIDs: cart.productIDs, treatments: applied)) // On the confirmation screen confirmationSlot.screen = .orderConfirmation(treatmentTypes: applied.map(\.type))}TreatmentClaim and AcmeOrderAPI are your own types. Send the token and the ids, never the value: your backend reads the value from the verified token.
activeTreatments() is safe from any thread. It returns offers in live slots: in a window, not hidden, with the app not in the background. An app that is only inactive, for example under a system payment sheet, still counts. 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. nil in demo mode |
isDemo |
true in demo mode |
Every property 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. A treatment’s description, debugDescription and dump() output leave the token out, so printing or dumping it does not put the grant in your logs.
Demo mode
Section titled “Demo mode”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.
Next steps
Section titled “Next steps”- Set up Grants and order verification on your server.
- Read Offers and grants for how offers are analysed.