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 off the main actor.
  3. Your grant source calls your backend, inside the shopper’s authenticated session.
  4. 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.
  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, enforces 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 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 async method. It returns the grants as compact JWS strings, or throws on failure.

AcmeGrantSource.swift
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 customerID argument 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.

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.

  • 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 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 Grona
@MainActor
func 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.

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.