Offers and grants
An offer that carries money, such as 10% off an order, renders only when the SDK holds a grant for it. A grant is a short-lived token that Grona’s server signs for one identified shopper, one campaign and one variant. This page explains why grants exist, what a grant contains, how the device uses it, and why your order backend has the final say.
Why grants exist
Section titled “Why grants exist”Three facts about a mobile app rule out trusting the device with money:
- The configuration is public. The workspace key ships in your app, so anyone can fetch the configuration. It cannot hold customer lists, so it cannot say who is eligible.
- The device user may be an attacker. Anything the app decides alone, the user can change: cached files, the clock, deep links, a repackaged build.
- Network data is untrusted, even over TLS. Integrity comes from signatures, not from the channel.
So money never rests on a client assertion. Grona’s server decides who is eligible, using customer-unit bucketing and segments the server knows, and signs a grant for that shopper. The device uses the grant to decide what to show. Your order backend verifies the same grant again before it applies anything, so a treatment the device reports is display state until then.
How a grant travels
Section titled “How a grant travels”-
The shopper signs in, and your app calls
identifywith their opaque customer id, for examplecus_7Hq2Lm9x. -
The SDK asks your app’s grant source for grants. Your grant source calls your backend inside the shopper’s authenticated session.
-
Your backend fetches the shopper’s grants from Grona, server to server, and returns them to the app unchanged.
-
The SDK verifies each grant and keeps the valid ones in memory.
-
An offer renders only when a grant matches its campaign, variant, treatment type and value.
-
At checkout, your app reads the SDK’s active treatments, each with its grant token, and sends the tokens with the order.
-
Your order backend verifies each token, re-checks the campaign’s conditions against the real order, uses each grant once, and applies the value from the verified claims.
The grant token
Section titled “The grant token”A grant is a compact JWS (RFC 7515, section 7.1) signed with ES256 by a Grona grant key. The SDK trusts the grant keys your app pins, the current key and the next one. The protected header holds exactly three fields:
{"alg": "ES256", "kid": "acme-market-grants-2026-10", "typ": "grona-grant+jwt"}Any other header field, another alg or typ, or an unknown kid rejects the grant.
| Claim | Type | Rule |
|---|---|---|
sub |
string | The customer id. It equals the id your app passed to identify |
wks |
string | The workspace key |
cmp |
id | The campaign id |
var |
id | The variant id |
trt |
id | The treatment type. It equals the campaign’s treatment type |
val |
int | The treatment value in thousandths, 0 or more. It equals the campaign’s treatment value |
iat |
int | Issued at, in Unix seconds between 2020-01-01 and 2100-01-01, and not more than 5 minutes ahead of the device clock |
exp |
int | Expiry, in Unix seconds between 2020-01-01 and 2100-01-01. It is after now, after iat, and at most 24 hours after iat |
jti |
id | A single-use nonce. Your order backend enforces single use |
prv |
bool | Always false. Preview never produces a grant |
Ids match [A-Za-z0-9._:-]{1,128}. Unknown claims are ignored, and a token is at most 4 KB. A decoded payload for Acme Market’s weekend offer looks like this, where val 10000 means 10, that is 10% off for a percent_off treatment:
{ "sub": "cus_7Hq2Lm9x", "wks": "acme-market", "cmp": "weekend-offer", "var": "B", "trt": "percent_off", "val": 10000, "iat": 1793264400, "exp": 1793268000, "jti": "9f1c2e7a4b6d8e0f1a3c5b7d", "prv": false}Treatment types are ids that the campaign defines. percent_off is this example’s type, not a fixed list.
On the device
Section titled “On the device”- Fetching. The SDK fetches grants when your app calls
identify, again shortly before the nearest grant expires, and at least once an hour while the shopper stays identified. A failed fetch is retried with backoff of up to 15 minutes. Each fetch waits at most 10 seconds and runs off the SDK’s own thread. The SDK keeps at most 50 grants. - Storage. Grants live in memory only. The SDK clears them when the customer changes and on reset.
- Matching. An offer renders only when a verified, unexpired grant matches its campaign, variant, treatment type and value. A value mismatch fails closed, because the copy would promise something your backend will not apply.
- No grant. Without a matching grant the shopper is not treated. The campaign leaves the placement to the next campaign, as a control arm does.
- Who can see offers. Every campaign with a money treatment uses the customer unit, so shoppers who are not identified never see an offer.
- Active treatments. The SDK’s active treatments are the offers rendered in a live slot, each with its grant token. A slot is live while it is attached, shown and in a visible window. A slot on a stopped or hidden screen keeps its content but is left out.
To connect a grant source and read active treatments, see Offers and grants for Android or iOS.
Delivery states
Section titled “Delivery states”Bucketing happens before the grant check. So a shopper in an offer’s treatment arm may never see the offer, and the events say why in a delivery field:
delivery |
Meaning |
|---|---|
granted |
A verified grant matched. grant_jti names it |
grants_pending |
No grant fetch has completed since identify |
no_grant |
A fetch completed and no grant matched, or the app has no grant source |
demo |
Demo mode rendered the offer without a grant |
preview |
A preview pin rendered the offer |
- The
eligibleevent keeps the assigned arm,treatment, and records the delivery state at the first decision of the visit. That is oftengrants_pending, becauseidentifyfetches grants in the background. - A grant that lands later in the visit makes the offer replace the fallback. That logs a
servedevent withdelivery: grantedand the grant’sjti, not a secondeligible. - Analysis takes the treated share from
servedevents, not from Grona’s issuance log. A minted grant can fail to reach the device, arrive after the shopper left, or expire first. See Events and exposure.
Preview and demo never grant
Section titled “Preview and demo never grant”- Preview. A preview pin can render an offer without a grant so a tester can check it. The offer never appears in the active treatments, and every event carries
preview: true. A pin never shows a customer-unit campaign to a shopper who is not identified, so logged-out testers never see an offer either. - Demo. Demo mode, which needs both an app option and a setting in the signed configuration, renders offers without a grant. Their treatments carry no token and are flagged as demo, and a real backend must ignore them. See Signed configuration.
Neither preview nor demo produces a grant, and every grant carries prv: false.
The order backend is the enforcement point
Section titled “The order backend is the enforcement point”Everything on the device is display. Money moves only when your order backend verifies the token:
- the signature, with a current grant key;
exp;subagainst the customer of the order;wks;prvisfalse;- single use on
jti; - the campaign’s basket conditions against the real order.
Anything that fails means no treatment, and the order still succeeds.
The value applied comes only from the verified claims: cmp, var, trt and val. The value, demo flag and placement that the app reports with a treatment, and the treatments in the app’s purchase event, are display data, and your backend ignores them.